mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-17 10:15:47 +00:00
Merge branch 'master' into badges
This commit is contained in:
@@ -21,7 +21,6 @@ struct CIFileView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
let file: CIFile?
|
||||
let meta: CIMeta
|
||||
let senderProfile: LocalProfile?
|
||||
var smallViewSize: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
@@ -91,19 +90,15 @@ struct CIFileView: View {
|
||||
if let file = file {
|
||||
switch (file.fileStatus) {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
if fileSizeValid(file, senderProfile) {
|
||||
if let prohibited = file.fileProhibited {
|
||||
showProhibitedFileAlert(file, prohibited)
|
||||
} else {
|
||||
Task {
|
||||
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in 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 {
|
||||
@@ -171,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, senderProfile) {
|
||||
if !fileSizeValid(file) {
|
||||
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
|
||||
} else if file.expired {
|
||||
fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
|
||||
@@ -235,13 +230,27 @@ struct CIFileView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool {
|
||||
// the core decides whether a received file is above the size the sender's badge allows
|
||||
func fileSizeValid(_ file: CIFile?) -> Bool {
|
||||
if let file = file {
|
||||
return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
|
||||
return file.fileProhibited == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func showProhibitedFileAlert(_ file: CIFile, _ prohibited: FileProhibited) {
|
||||
let badgeIssue = switch prohibited.badgeStatus {
|
||||
case .none, .some(.active): ""
|
||||
case .some(.expired), .some(.expiredOld): NSLocalizedString("Contact's badge expired.", comment: "file alert")
|
||||
case .some(.failed): NSLocalizedString("Contact's badge verification failed.", comment: "file alert")
|
||||
case .some(.unknownKey): NSLocalizedString("No key to verify contact's badge.", comment: "file alert")
|
||||
}
|
||||
showAlert(
|
||||
NSLocalizedString("Large file!", comment: "file alert title"),
|
||||
message: largeFileMessage(file.fileSize, badgeIssue: badgeIssue)
|
||||
)
|
||||
}
|
||||
|
||||
func saveCryptoFile(_ fileSource: CryptoFile) {
|
||||
if let cfArgs = fileSource.cryptoArgs {
|
||||
let url = getAppFilePath(fileSource.filePath)
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -52,18 +51,14 @@ struct CIImageView: View {
|
||||
if let file = file {
|
||||
switch file.fileStatus {
|
||||
case .rcvInvitation, .rcvAborted:
|
||||
if fileSizeValid(file, senderProfile) {
|
||||
if let prohibited = file.fileProhibited {
|
||||
showProhibitedFileAlert(file, prohibited)
|
||||
} else {
|
||||
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 {
|
||||
@@ -152,7 +147,7 @@ struct CIImageView: View {
|
||||
case .sndCancelled: fileIcon("xmark", 10, 13)
|
||||
case .sndError: fileIcon("xmark", 10, 13)
|
||||
case .sndWarning: fileIcon("exclamationmark.triangle.fill", 10, 13)
|
||||
case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file, senderProfile) ? "xmark" : "arrow.down", 10, 13)
|
||||
case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file) ? "xmark" : "arrow.down", 10, 13)
|
||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case .rcvTransfer: progressView()
|
||||
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
|
||||
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -36,9 +35,8 @@ 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, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
|
||||
self.chatItem = chatItem
|
||||
self.senderProfile = senderProfile
|
||||
self.preview = preview
|
||||
self._duration = State(initialValue: duration)
|
||||
self.maxWidth = maxWidth
|
||||
@@ -368,7 +366,7 @@ struct CIVideoView: View {
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showFileErrorAlert(sndFileError, temporary: true)
|
||||
})
|
||||
case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file, senderProfile) ? "xmark" : "arrow.down", 10, 13)
|
||||
case .rcvInvitation: fileIcon(file.expired && fileSizeValid(file) ? "xmark" : "arrow.down", 10, 13)
|
||||
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
|
||||
case let .rcvTransfer(rcvProgress, rcvTotal):
|
||||
if file.fileProtocol == .xftp && rcvProgress < rcvTotal {
|
||||
@@ -423,18 +421,14 @@ struct CIVideoView: View {
|
||||
|
||||
// TODO encrypt: where file size is checked?
|
||||
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
|
||||
if fileSizeValid(file, senderProfile) {
|
||||
if let prohibited = file.fileProhibited {
|
||||
showProhibitedFileAlert(file, prohibited)
|
||||
} else {
|
||||
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))."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ struct FramedItemView: View {
|
||||
} else {
|
||||
switch (chatItem.content.msgContent) {
|
||||
case let .image(text, _):
|
||||
CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
|
||||
CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
@@ -170,7 +170,7 @@ struct FramedItemView: View {
|
||||
ciMsgContentView(chatItem)
|
||||
}
|
||||
case let .video(text, _, duration):
|
||||
CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
|
||||
CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
|
||||
.overlay(DetermineWidth())
|
||||
if text == "" && !chatItem.meta.isLive {
|
||||
Color.clear
|
||||
@@ -387,7 +387,7 @@ struct FramedItemView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
|
||||
CIFileView(chat: chat, file: chatItem.file, meta: chatItem.meta, senderProfile: ciSenderProfile(chatItem, chat.chatInfo))
|
||||
CIFileView(chat: chat, file: chatItem.file, meta: chatItem.meta)
|
||||
.overlay(DetermineWidth())
|
||||
if text != "" || ci.meta.isLive {
|
||||
ciMsgContentView (chatItem)
|
||||
|
||||
@@ -2322,7 +2322,7 @@ struct ChatView: View {
|
||||
} else {
|
||||
saveButton(file: fileSource)
|
||||
}
|
||||
} else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) {
|
||||
} else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) {
|
||||
downloadButton(file: file)
|
||||
}
|
||||
if ci.meta.editable && !mc.isVoice && !live {
|
||||
|
||||
@@ -668,10 +668,9 @@ struct ComposeView: View {
|
||||
fileSize <= maxFileSize {
|
||||
composeState = composeState.copy(preview: .filePreview(fileName: fileURL.lastPathComponent, file: fileURL))
|
||||
} else {
|
||||
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: maxFileSize, countStyle: .binary)
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
|
||||
showAlert(
|
||||
NSLocalizedString("Large file!", comment: "file alert title"),
|
||||
message: largeFileMessage(Int64(fileSize ?? 0), incognito: sendIncognito, badgeIssue: expiredBadgeReason(Int64(fileSize ?? 0), sendProfile))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
@@ -1266,12 +1265,15 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var maxFileSize: Int64 {
|
||||
// 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)
|
||||
// no badge is presented in incognito chats, so it does not raise the limit there
|
||||
private var sendIncognito: Bool {
|
||||
chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
|
||||
}
|
||||
|
||||
private var sendProfile: LocalProfile? { sendIncognito ? nil : chatModel.currentUser?.profile }
|
||||
|
||||
private var maxFileSize: Int64 { getMaxFileSize(.xftp, sendProfile) }
|
||||
|
||||
// Spec: spec/client/compose.md#sendLiveMessage
|
||||
private func sendLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
|
||||
@@ -426,11 +426,11 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
case let .image(_, image):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
|
||||
}
|
||||
case let .video(_,image, duration):
|
||||
smallContentPreview(size: dynamicMediaSize) {
|
||||
CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
|
||||
}
|
||||
case let .voice(_, duration):
|
||||
smallContentPreviewVoice(size: dynamicMediaSize) {
|
||||
@@ -438,7 +438,7 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
case .file:
|
||||
smallContentPreviewFile(size: dynamicMediaSize) {
|
||||
CIFileView(chat: chat, file: ci.file, meta: ci.meta, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize)
|
||||
CIFileView(chat: chat, file: ci.file, meta: ci.meta, smallViewSize: dynamicMediaSize)
|
||||
}
|
||||
case let .chat(_, chatLink, ownerSig):
|
||||
smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) {
|
||||
|
||||
@@ -97,37 +97,42 @@ class ShareModel: ObservableObject {
|
||||
if let e = initChat(with: dbKey) {
|
||||
await MainActor.run { errorAlert = e }
|
||||
} else {
|
||||
// Load Chats
|
||||
Task {
|
||||
switch fetchChats() {
|
||||
case let .success(chats):
|
||||
// Decode base64 images on background thread
|
||||
let profileImages = chats.reduce(into: Dictionary<ChatInfo.ID, UIImage>()) { dict, chatData in
|
||||
if let profileImage = chatData.chatInfo.image,
|
||||
let uiImage = imageFromBase64(profileImage) {
|
||||
dict[chatData.id] = uiImage
|
||||
switch activeUser() {
|
||||
case let .failure(error):
|
||||
await MainActor.run { errorAlert = error }
|
||||
case let .success(user):
|
||||
// Load Chats
|
||||
Task {
|
||||
switch fetchChats(user) {
|
||||
case let .success(chats):
|
||||
// Decode base64 images on background thread
|
||||
let profileImages = chats.reduce(into: Dictionary<ChatInfo.ID, UIImage>()) { dict, chatData in
|
||||
if let profileImage = chatData.chatInfo.image,
|
||||
let uiImage = imageFromBase64(profileImage) {
|
||||
dict[chatData.id] = uiImage
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
self.chats = chats
|
||||
self.profileImages = profileImages
|
||||
withAnimation { isLoaded = true }
|
||||
}
|
||||
case let .failure(error):
|
||||
await MainActor.run { errorAlert = error }
|
||||
}
|
||||
await MainActor.run {
|
||||
self.chats = chats
|
||||
self.profileImages = profileImages
|
||||
withAnimation { isLoaded = true }
|
||||
}
|
||||
case let .failure(error):
|
||||
await MainActor.run { errorAlert = error }
|
||||
}
|
||||
}
|
||||
// Process Attachment
|
||||
Task {
|
||||
switch await getSharedContent(self.itemProvider!) {
|
||||
case let .success(chatItemContent):
|
||||
await MainActor.run {
|
||||
self.sharedContent = chatItemContent
|
||||
self.bottomBar = .sendButton
|
||||
if case let .text(string) = chatItemContent { comment = string }
|
||||
// Process Attachment
|
||||
Task {
|
||||
switch await getSharedContent(self.itemProvider!, user.profile) {
|
||||
case let .success(chatItemContent):
|
||||
await MainActor.run {
|
||||
self.sharedContent = chatItemContent
|
||||
self.bottomBar = .sendButton
|
||||
if case let .text(string) = chatItemContent { comment = string }
|
||||
}
|
||||
case let .failure(errorAlert):
|
||||
await MainActor.run { self.errorAlert = errorAlert }
|
||||
}
|
||||
case let .failure(errorAlert):
|
||||
await MainActor.run { self.errorAlert = errorAlert }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,7 +258,7 @@ class ShareModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchChats() -> Result<Array<SEChatData>, ErrorAlert> {
|
||||
private func activeUser() -> Result<User, ErrorAlert> {
|
||||
do {
|
||||
guard let user = try apiGetActiveUser() else {
|
||||
return .failure(
|
||||
@@ -263,6 +268,14 @@ class ShareModel: ObservableObject {
|
||||
)
|
||||
)
|
||||
}
|
||||
return .success(user)
|
||||
} catch {
|
||||
return .failure(ErrorAlert(error))
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchChats(_ user: User) -> Result<Array<SEChatData>, ErrorAlert> {
|
||||
do {
|
||||
return .success(try apiGetChats(userId: user.id))
|
||||
} catch {
|
||||
return .failure(ErrorAlert(error))
|
||||
@@ -405,7 +418,7 @@ enum SharedContent {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedContent, ErrorAlert> {
|
||||
fileprivate func getSharedContent(_ ip: NSItemProvider, _ senderProfile: LocalProfile) async -> Result<SharedContent, ErrorAlert> {
|
||||
if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) {
|
||||
switch type {
|
||||
// Prepare Image message
|
||||
@@ -443,15 +456,13 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedCo
|
||||
// Prepare Data message
|
||||
case .fileURL:
|
||||
if let url = try? await inPlaceUrl(type: .data) {
|
||||
if isFileTooLarge(for: url) {
|
||||
let sizeString = ByteCountFormatter.string(
|
||||
fromByteCount: Int64(getMaxFileSize(.xftp)),
|
||||
countStyle: .binary
|
||||
)
|
||||
let size = Int64(fileSize(url) ?? 0)
|
||||
let maxSize = getMaxFileSize(.xftp, senderProfile)
|
||||
if size > maxSize {
|
||||
return .failure(
|
||||
ErrorAlert(
|
||||
title: "Large file!",
|
||||
message: "Currently maximum supported file size is \(sizeString)."
|
||||
message: LocalizedStringKey(largeFileMessage(size, badgeIssue: expiredBadgeReason(size, senderProfile)))
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -534,9 +545,3 @@ fileprivate func transcodeVideo(from input: URL) async -> URL? {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func isFileTooLarge(for url: URL) -> Bool {
|
||||
fileSize(url)
|
||||
.map { $0 > getMaxFileSize(.xftp) }
|
||||
?? false
|
||||
}
|
||||
|
||||
|
||||
@@ -191,8 +191,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-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */; };
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */; };
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a */; };
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.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 */; };
|
||||
@@ -580,8 +580,8 @@
|
||||
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a"; sourceTree = "<group>"; };
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a"; sourceTree = "<group>"; };
|
||||
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
|
||||
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
|
||||
@@ -752,8 +752,8 @@
|
||||
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
|
||||
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
|
||||
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a in Frameworks */,
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a in Frameworks */,
|
||||
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a in Frameworks */,
|
||||
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -840,8 +840,8 @@
|
||||
64C829992D54AEEE006B9E89 /* libffi.a */,
|
||||
64C829982D54AEED006B9E89 /* libgmp.a */,
|
||||
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ-ghc9.6.3.a */,
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-JuaCEFy75SxELqTayb7fbQ.a */,
|
||||
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS-ghc9.6.3.a */,
|
||||
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.4-4q834kD5SZSFNbM5stNfdS.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
|
||||
@@ -4689,6 +4689,17 @@ extension MsgReaction: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
// set by the core when the file is above the size the sender's badge allows; badgeStatus is nil when no proof was sent
|
||||
public struct FileProhibited: Decodable, Hashable {
|
||||
public var maxSize: Int64
|
||||
public var badgeStatus: BadgeStatus?
|
||||
|
||||
public init(maxSize: Int64, badgeStatus: BadgeStatus?) {
|
||||
self.maxSize = maxSize
|
||||
self.badgeStatus = badgeStatus
|
||||
}
|
||||
}
|
||||
|
||||
public struct CIFile: Decodable, Hashable {
|
||||
public var fileId: Int64
|
||||
public var fileName: String
|
||||
@@ -4697,6 +4708,7 @@ public struct CIFile: Decodable, Hashable {
|
||||
public var fileStatus: CIFileStatus
|
||||
public var fileProtocol: FileProtocol
|
||||
public var fileExpires: Date? = nil
|
||||
public var fileProhibited: FileProhibited? = nil
|
||||
|
||||
public static func getSample(fileId: Int64 = 1, fileName: String = "test.txt", fileSize: Int64 = 100, filePath: String? = "test.txt", fileStatus: CIFileStatus = .rcvComplete) -> CIFile {
|
||||
let f: CryptoFile?
|
||||
|
||||
@@ -29,10 +29,13 @@ 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
|
||||
// raised XFTP limits for a user 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
|
||||
|
||||
// a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace
|
||||
public let BADGE_SND_GRACE_INTERVAL = TimeInterval(86400)
|
||||
|
||||
public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max
|
||||
|
||||
public let MAX_FILE_SIZE_SMP: Int64 = 8000000
|
||||
@@ -277,29 +280,63 @@ public func cleanupFile(_ aChatItem: AChatItem) {
|
||||
}
|
||||
}
|
||||
|
||||
public func badgeMaxFileSize(_ badge: LocalBadge) -> Int64 {
|
||||
badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER
|
||||
}
|
||||
|
||||
// a badge raises the limit at send until one day past its expiry, as the core applies it
|
||||
public func badgeActiveForSend(_ badge: LocalBadge) -> Bool {
|
||||
badge.status == .active && badge.badge.badgeExpiry.addingTimeInterval(BADGE_SND_GRACE_INTERVAL) >= Date.now
|
||||
}
|
||||
|
||||
// in incognito chats and above the largest badge's limit no badge applies, so badgeIssue is not used
|
||||
public func largeFileMessage(_ fileSize: Int64, incognito: Bool = false, badgeIssue: String = "") -> String {
|
||||
if incognito {
|
||||
return String.localizedStringWithFormat(
|
||||
NSLocalizedString("Files larger than %@ cannot be sent in incognito chats.", comment: "file alert"),
|
||||
ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP, countStyle: .binary)
|
||||
)
|
||||
}
|
||||
if fileSize > MAX_FILE_SIZE_XFTP_LEGEND {
|
||||
return String.localizedStringWithFormat(
|
||||
NSLocalizedString("Maximum supported file size is %1$@, with a %2$@.", comment: "file alert"),
|
||||
ByteCountFormatter.string(fromByteCount: MAX_FILE_SIZE_XFTP_LEGEND, countStyle: .binary),
|
||||
NSLocalizedString("legend badge", comment: "badge required to send a large file")
|
||||
)
|
||||
}
|
||||
let supporter = fileSize <= MAX_FILE_SIZE_XFTP_SUPPORTER
|
||||
let message = String.localizedStringWithFormat(
|
||||
NSLocalizedString("You need a %1$@ to send files larger than %2$@.", comment: "file alert"),
|
||||
supporter
|
||||
? NSLocalizedString("supporter badge", comment: "badge required to send a large file")
|
||||
: NSLocalizedString("legend badge", comment: "badge required to send a large file"),
|
||||
ByteCountFormatter.string(fromByteCount: supporter ? MAX_FILE_SIZE_XFTP : MAX_FILE_SIZE_XFTP_SUPPORTER, countStyle: .binary)
|
||||
)
|
||||
return badgeIssue.isEmpty ? message : message + " " + badgeIssue
|
||||
}
|
||||
|
||||
// the badge lapsed, and while active it would have allowed this file
|
||||
public func expiredBadgeReason(_ fileSize: Int64, _ senderProfile: LocalProfile?) -> String {
|
||||
if let badge = senderProfile?.localBadge, !badgeActiveForSend(badge), badgeMaxFileSize(badge) >= fileSize {
|
||||
NSLocalizedString("Your badge expired.", comment: "file alert")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 {
|
||||
switch fileProtocol {
|
||||
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
|
||||
if let badge = senderProfile?.localBadge, badgeActiveForSend(badge) {
|
||||
badgeMaxFileSize(badge)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuntimeError: Error {
|
||||
let message: String
|
||||
|
||||
|
||||
+9
-1
@@ -4245,6 +4245,13 @@ enum class MREmojiChar(val value: String) {
|
||||
@SerialName("✅") Check("✅");
|
||||
}
|
||||
|
||||
// set by the core when the file is above the size the sender's badge allows; badgeStatus is null when no proof was sent
|
||||
@Serializable
|
||||
data class FileProhibited(
|
||||
val maxSize: Long,
|
||||
val badgeStatus: BadgeStatus? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CIFile(
|
||||
val fileId: Long,
|
||||
@@ -4253,7 +4260,8 @@ data class CIFile(
|
||||
val fileSource: CryptoFile? = null,
|
||||
val fileStatus: CIFileStatus,
|
||||
val fileProtocol: FileProtocol,
|
||||
val fileExpires: Instant? = null
|
||||
val fileExpires: Instant? = null,
|
||||
val fileProhibited: FileProhibited? = null
|
||||
) {
|
||||
val expired: Boolean = fileExpires != null && fileExpires < Clock.System.now()
|
||||
|
||||
|
||||
+8
-4
@@ -115,7 +115,8 @@ data class ComposeState(
|
||||
val useLinkPreviews: Boolean,
|
||||
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)
|
||||
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP),
|
||||
val sendIncognito: Boolean = false
|
||||
) {
|
||||
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
|
||||
ComposeMessage(
|
||||
@@ -331,7 +332,7 @@ fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
|
||||
} else if (fileSize != null) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
largeFileMessage(fileSize, value.sendIncognito, expiredBadgeReason(fileSize, chatModel.currentUser.value?.profile))
|
||||
)
|
||||
} else {
|
||||
showWrongUriAlert()
|
||||
@@ -360,7 +361,7 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
|
||||
bitmap = null
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||
largeFileMessage(fileSize ?: 0, value.sendIncognito, expiredBadgeReason(fileSize ?: 0, chatModel.currentUser.value?.profile))
|
||||
)
|
||||
null
|
||||
}
|
||||
@@ -1420,7 +1421,10 @@ fun ComposeView(
|
||||
// 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))
|
||||
composeState.value = composeState.value.copy(
|
||||
maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile),
|
||||
sendIncognito = incognito
|
||||
)
|
||||
}
|
||||
if (appPlatform.isDesktop) {
|
||||
// the same ComposeView is reused when switching chats, so `chat` captured by onDispose would be the chat opened first, not the current one
|
||||
|
||||
+20
-11
@@ -38,7 +38,6 @@ fun CIFileView(
|
||||
showTimestamp: Boolean,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean = false,
|
||||
senderProfile: LocalProfile?,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
|
||||
@@ -77,13 +76,11 @@ fun CIFileView(
|
||||
if (file != null) {
|
||||
when {
|
||||
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> {
|
||||
if (fileSizeValid(file, senderProfile)) {
|
||||
receiveFile(file.fileId)
|
||||
val prohibited = file.fileProhibited
|
||||
if (prohibited != null) {
|
||||
showProhibitedFileAlert(file, prohibited)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
|
||||
)
|
||||
receiveFile(file.fileId)
|
||||
}
|
||||
}
|
||||
file.fileStatus is CIFileStatus.RcvAccepted ->
|
||||
@@ -157,7 +154,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, senderProfile))
|
||||
if (!fileSizeValid(file))
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
|
||||
else if (file.expired)
|
||||
fileIcon(innerIcon = painterResource(MR.images.ic_close))
|
||||
@@ -239,9 +236,21 @@ fun CIFileView(
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// the core decides whether a received file is above the size the sender's badge allows
|
||||
fun fileSizeValid(file: CIFile): Boolean = file.fileProhibited == null
|
||||
|
||||
fun showProhibitedFileAlert(file: CIFile, prohibited: FileProhibited) {
|
||||
val badgeIssue = when (prohibited.badgeStatus) {
|
||||
null, BadgeStatus.Active -> ""
|
||||
BadgeStatus.Expired, BadgeStatus.ExpiredOld -> generalGetString(MR.strings.badge_expired)
|
||||
BadgeStatus.Failed -> generalGetString(MR.strings.badge_verification_failed)
|
||||
BadgeStatus.UnknownKey -> generalGetString(MR.strings.badge_no_key)
|
||||
}
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
largeFileMessage(file.fileSize, badgeIssue = badgeIssue)
|
||||
)
|
||||
}
|
||||
|
||||
fun showFileErrorAlert(err: FileError, file: CIFile? = null, temporary: Boolean = false) {
|
||||
val fileExpires = file?.fileExpires
|
||||
|
||||
+4
-8
@@ -38,7 +38,6 @@ fun CIImageView(
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean,
|
||||
senderProfile: LocalProfile?,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
|
||||
@@ -84,7 +83,7 @@ fun CIImageView(
|
||||
is CIFileStatus.SndError -> fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.SndWarning -> fileIcon(painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file)
|
||||
is CIFileStatus.RcvInvitation ->
|
||||
if (file.expired && fileSizeValid(file, senderProfile))
|
||||
if (file.expired && fileSizeValid(file))
|
||||
fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
else
|
||||
fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_asked_to_receive)
|
||||
@@ -220,13 +219,10 @@ fun CIImageView(
|
||||
if (file != null) {
|
||||
when {
|
||||
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted ->
|
||||
if (fileSizeValid(file, senderProfile)) {
|
||||
receiveFile(file.fileId)
|
||||
if (file.fileProhibited != null) {
|
||||
showProhibitedFileAlert(file, file.fileProhibited)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
|
||||
)
|
||||
receiveFile(file.fileId)
|
||||
}
|
||||
file.fileStatus is CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
|
||||
+10
-14
@@ -35,7 +35,6 @@ fun CIVideoView(
|
||||
imageProvider: () -> ImageGalleryProvider,
|
||||
showMenu: MutableState<Boolean>,
|
||||
smallView: Boolean = false,
|
||||
senderProfile: LocalProfile?,
|
||||
receiveFile: (Long) -> Unit
|
||||
) {
|
||||
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
|
||||
@@ -99,7 +98,7 @@ fun CIVideoView(
|
||||
if (file != null) {
|
||||
when (file.fileStatus) {
|
||||
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
|
||||
receiveFileIfValidSize(file, senderProfile, receiveFile)
|
||||
receiveFileIfValidSize(file, receiveFile)
|
||||
CIFileStatus.RcvAccepted ->
|
||||
when (file.fileProtocol) {
|
||||
FileProtocol.XFTP ->
|
||||
@@ -129,16 +128,16 @@ 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, senderProfile, receiveFile) }
|
||||
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Do not show download icon when the view is blurred
|
||||
if (!smallView && (!showDownloadButton(file?.fileStatus) || !blurred.value)) {
|
||||
fileStatusIcon(file, false, senderProfile)
|
||||
fileStatusIcon(file, false)
|
||||
} else if (smallView && file?.showStatusIconInSmallView == true) {
|
||||
Box(Modifier.align(Alignment.Center)) {
|
||||
fileStatusIcon(file, true, senderProfile)
|
||||
fileStatusIcon(file, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -486,7 +485,7 @@ private fun progressCircle(progress: Long, total: Long) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: LocalProfile?) {
|
||||
private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
|
||||
if (file != null) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -526,7 +525,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: Loc
|
||||
}
|
||||
)
|
||||
is CIFileStatus.RcvInvitation ->
|
||||
if (file.expired && fileSizeValid(file, senderProfile))
|
||||
if (file.expired && fileSizeValid(file))
|
||||
fileIcon(painterResource(MR.images.ic_close), MR.strings.icon_descr_file)
|
||||
else
|
||||
fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive)
|
||||
@@ -565,14 +564,11 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean, senderProfile: Loc
|
||||
private fun showDownloadButton(status: CIFileStatus?): Boolean =
|
||||
status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted
|
||||
|
||||
private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) {
|
||||
if (fileSizeValid(file, senderProfile)) {
|
||||
receiveFile(file.fileId)
|
||||
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||
if (file.fileProhibited != null) {
|
||||
showProhibitedFileAlert(file, file.fileProhibited)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.large_file),
|
||||
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
|
||||
)
|
||||
receiveFile(file.fileId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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, ciSenderProfile(cItem, chat.chatInfo))) {
|
||||
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) {
|
||||
ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = {
|
||||
withBGApi {
|
||||
Log.d(TAG, "ChatItemView downloadFileAction")
|
||||
|
||||
+3
-3
@@ -219,7 +219,7 @@ fun FramedItemView(
|
||||
|
||||
@Composable
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
|
||||
CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, receiveFile)
|
||||
if (text != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
@@ -364,7 +364,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, ciSenderProfile(ci, chatInfo), receiveFile)
|
||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
@@ -372,7 +372,7 @@ fun FramedItemView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile)
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile)
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
|
||||
+3
-3
@@ -324,13 +324,13 @@ fun ChatPreviewView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCImage -> SmallContentPreview {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
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, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
|
||||
val user = chatModel.currentUser.value ?: return@CIVideoView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
@@ -342,7 +342,7 @@ fun ChatPreviewView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> SmallContentPreviewFile {
|
||||
CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
|
||||
CIFileView(ci.file, ci.meta, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, showMenu = remember { mutableStateOf(false) }, smallView = true) {
|
||||
val user = chatModel.currentUser.value ?: return@CIFileView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
|
||||
+39
-12
@@ -18,6 +18,7 @@ import com.charleskorn.kaml.decodeFromStream
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
@@ -27,6 +28,8 @@ import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.math.*
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
@@ -123,10 +126,13 @@ 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
|
||||
// raised XFTP limits for a user 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
|
||||
|
||||
// a badge raises the limit at send for this long after its expiry, shorter than the receiver's grace
|
||||
val BADGE_SND_GRACE_INTERVAL: Duration = 1.days
|
||||
|
||||
const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE
|
||||
|
||||
expect fun getAppFileUri(fileName: String): URI
|
||||
@@ -474,25 +480,46 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
|
||||
return fileCount to bytes
|
||||
}
|
||||
|
||||
fun badgeMaxFileSize(badge: LocalBadge): Long =
|
||||
if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND else MAX_FILE_SIZE_XFTP_SUPPORTER
|
||||
|
||||
// a badge raises the limit at send until one day past its expiry, as the core applies it
|
||||
fun badgeActiveForSend(badge: LocalBadge): Boolean =
|
||||
badge.status == BadgeStatus.Active && badge.badge.badgeExpiry + BADGE_SND_GRACE_INTERVAL >= Clock.System.now()
|
||||
|
||||
// in incognito chats and above the largest badge's limit no badge applies, so badgeIssue is not used
|
||||
fun largeFileMessage(fileSize: Long, incognito: Boolean = false, badgeIssue: String = ""): String =
|
||||
if (incognito) {
|
||||
String.format(generalGetString(MR.strings.large_file_incognito), formatBytes(MAX_FILE_SIZE_XFTP))
|
||||
} else if (fileSize > MAX_FILE_SIZE_XFTP_LEGEND) {
|
||||
String.format(generalGetString(MR.strings.max_file_size_with_badge), formatBytes(MAX_FILE_SIZE_XFTP_LEGEND), generalGetString(MR.strings.legend_badge))
|
||||
} else {
|
||||
val supporter = fileSize <= MAX_FILE_SIZE_XFTP_SUPPORTER
|
||||
val message = String.format(
|
||||
generalGetString(MR.strings.large_file_requires_badge),
|
||||
generalGetString(if (supporter) MR.strings.supporter_badge else MR.strings.legend_badge),
|
||||
formatBytes(if (supporter) MAX_FILE_SIZE_XFTP else MAX_FILE_SIZE_XFTP_SUPPORTER)
|
||||
)
|
||||
if (badgeIssue.isEmpty()) message else message + " " + badgeIssue
|
||||
}
|
||||
|
||||
// the badge lapsed, and while active it would have allowed this file
|
||||
fun expiredBadgeReason(fileSize: Long, senderProfile: LocalProfile?): String {
|
||||
val badge = senderProfile?.localBadge
|
||||
return if (badge != null && !badgeActiveForSend(badge) && badgeMaxFileSize(badge) >= fileSize) {
|
||||
generalGetString(MR.strings.your_badge_expired)
|
||||
} else ""
|
||||
}
|
||||
|
||||
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
|
||||
if (badge != null && badgeActiveForSend(badge)) badgeMaxFileSize(badge) else MAX_FILE_SIZE_XFTP
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Whether the file really contains a video track. Reads container metadata only, without decoding a frame.
|
||||
|
||||
@@ -687,6 +687,15 @@
|
||||
<string name="icon_descr_file">File</string>
|
||||
<string name="large_file">Large file!</string>
|
||||
<string name="contact_sent_large_file">Your contact sent a file that is larger than currently supported maximum size (%1$s).</string>
|
||||
<string name="supporter_badge">supporter badge</string>
|
||||
<string name="legend_badge">legend badge</string>
|
||||
<string name="large_file_requires_badge">You need a %1$s to send files larger than %2$s.</string>
|
||||
<string name="max_file_size_with_badge">Maximum supported file size is %1$s, with a %2$s.</string>
|
||||
<string name="badge_expired">Contact\'s badge expired.</string>
|
||||
<string name="badge_verification_failed">Contact\'s badge verification failed.</string>
|
||||
<string name="badge_no_key">No key to verify contact\'s badge.</string>
|
||||
<string name="your_badge_expired">Your badge expired.</string>
|
||||
<string name="large_file_incognito">Files larger than %1$s cannot be sent in incognito chats.</string>
|
||||
<string name="maximum_supported_file_size">Currently maximum supported file size is %1$s.</string>
|
||||
<string name="waiting_for_file">Waiting for file</string>
|
||||
<string name="file_will_be_received_when_contact_completes_uploading">File will be received when your contact completes uploading it.</string>
|
||||
|
||||
@@ -41,6 +41,7 @@ data DirectoryOpts = DirectoryOpts
|
||||
linkCheckInterval :: Int,
|
||||
prohibitedToObserver :: Bool,
|
||||
alwaysCaptcha :: Bool,
|
||||
alwaysObserver :: Bool,
|
||||
knocking :: Bool,
|
||||
testing :: Bool
|
||||
}
|
||||
@@ -170,6 +171,11 @@ directoryOpts appDir defaultDbName = do
|
||||
( long "always-captcha"
|
||||
<> help "Require a captcha from joining members in all groups, regardless of per-group filter settings"
|
||||
)
|
||||
alwaysObserver <-
|
||||
switch
|
||||
( long "always-observer"
|
||||
<> help "Make joining members observers in all groups, regardless of per-group setting in directory"
|
||||
)
|
||||
knocking <-
|
||||
switch
|
||||
( long "knocking"
|
||||
@@ -197,6 +203,7 @@ directoryOpts appDir defaultDbName = do
|
||||
linkCheckInterval,
|
||||
prohibitedToObserver,
|
||||
alwaysCaptcha,
|
||||
alwaysObserver,
|
||||
knocking,
|
||||
testing = False
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ directoryService opts cfg = do
|
||||
|
||||
acceptMemberHook :: DirectoryOpts -> ServiceState -> GroupInfo -> GroupLinkInfo -> Profile -> IO (Either GroupRejectionReason (GroupAcceptance, GroupMemberRole))
|
||||
acceptMemberHook
|
||||
DirectoryOpts {profileNameLimit, alwaysCaptcha, knocking}
|
||||
DirectoryOpts {profileNameLimit, alwaysCaptcha, alwaysObserver, knocking}
|
||||
ServiceState {blockedWordsCfg}
|
||||
g
|
||||
GroupLinkInfo {memberRole}
|
||||
@@ -279,7 +279,7 @@ acceptMemberHook
|
||||
if
|
||||
| knocking -> (GAPendingReview, memberRole)
|
||||
| alwaysCaptcha || useMemberFilter img (passCaptcha a) -> (GAPendingApproval, GRMember)
|
||||
| useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver)
|
||||
| alwaysObserver || useMemberFilter img (makeObserver a) -> (GAAccepted, GRObserver)
|
||||
| otherwise -> (GAAccepted, memberRole)
|
||||
where
|
||||
checkName :: ExceptT GroupRejectionReason IO ()
|
||||
@@ -314,7 +314,7 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na
|
||||
pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling}
|
||||
|
||||
directoryServiceEvent :: DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO ()
|
||||
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests} user@User {userId} cc = \case
|
||||
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha, alwaysObserver} env@ServiceState {searchRequests} user@User {userId} cc = \case
|
||||
DEContactConnected ct -> deContactConnected ct
|
||||
DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole
|
||||
DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner
|
||||
@@ -678,7 +678,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o
|
||||
approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO ()
|
||||
approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do
|
||||
gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId)
|
||||
let role = if useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_
|
||||
let role = if alwaysObserver || useMemberFilter image (makeObserver a) then GRObserver else maybe GRMember (\GroupLinkInfo {memberRole} -> memberRole) gli_
|
||||
gmId = groupMemberId' m
|
||||
sendChatCmd cc (APIAcceptMember groupId gmId role) >>= \case
|
||||
Right CRMemberAccepted {member} -> do
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {isWeekend} from "./util.js"
|
||||
|
||||
export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI.
|
||||
*Join public groups* at https://simplex.chat/directory or [via directory bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok)
|
||||
|
||||
We just launched [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat)!
|
||||
Discover public groups: [simplex.chat/directory](https://simplex.chat/directory)
|
||||
|
||||
Please ask any questions about SimpleX Chat and about our crowdfunding.`
|
||||
Join the livestream about SimpleX roadmap and equity crowdfunding: [simplex.chat/livestream](https://simplex.chat/livestream) (September 15, at 17:00 UTC)`
|
||||
|
||||
export function queueMessage(timezone: string, grokEnabled: boolean): string {
|
||||
const hours = isWeekend(timezone) ? "48" : "24"
|
||||
|
||||
+123
-7
@@ -79,6 +79,10 @@ This file is generated automatically.
|
||||
- [StartChat](#startchat)
|
||||
- [APIStopChat](#apistopchat)
|
||||
|
||||
[Remote control commands](#remote-control-commands)
|
||||
- [ConnectRemoteCtrl](#connectremotectrl)
|
||||
- [VerifyRemoteCtrlSession](#verifyremotectrlsession)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -231,6 +235,10 @@ UserProfileUpdated: User profile updated.
|
||||
- toProfile: [Profile](./TYPES.md#profile)
|
||||
- updateSummary: [UserProfileUpdateSummary](./TYPES.md#userprofileupdatesummary)
|
||||
|
||||
UserProfileNoChange: User profile was not changed.
|
||||
- type: "userProfileNoChange"
|
||||
- user: [User](./TYPES.md#user)
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
- chatError: [ChatError](./TYPES.md#chaterror)
|
||||
@@ -505,15 +513,15 @@ Share user address card
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/_share address<str(toSendRef)>
|
||||
/_share address <str(toSendRef)>
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/_share address' + ChatRef.cmdString(toSendRef) // JavaScript
|
||||
'/_share address ' + ChatRef.cmdString(toSendRef) // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/_share address' + ChatRef_cmd_string(toSendRef) # Python
|
||||
'/_share address ' + ChatRef_cmd_string(toSendRef) # Python
|
||||
```
|
||||
|
||||
**Response**:
|
||||
@@ -1609,6 +1617,33 @@ SentInvitation: Invitation sent to contact address.
|
||||
- connection: [PendingContactConnection](./TYPES.md#pendingcontactconnection)
|
||||
- customUserProfile: [Profile](./TYPES.md#profile)?
|
||||
|
||||
ConnectionPlan: Connection link information.
|
||||
- type: "connectionPlan"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- connLink: [CreatedConnLink](./TYPES.md#createdconnlink)
|
||||
- planSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)?
|
||||
- otherSimplexName: [SimplexNameInfo](./TYPES.md#simplexnameinfo)?
|
||||
- connectionPlan: [ConnectionPlan](./TYPES.md#connectionplan)
|
||||
|
||||
SentInvitationToContact: Invitation sent to contact (when connecting via SimpleX name to a known contact address)..
|
||||
- type: "sentInvitationToContact"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- contact: [Contact](./TYPES.md#contact)
|
||||
- customUserProfile: [Profile](./TYPES.md#profile)?
|
||||
|
||||
StartedConnectionToContact: Connection to contact started (when connecting via prepared contact)..
|
||||
- type: "startedConnectionToContact"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- contact: [Contact](./TYPES.md#contact)
|
||||
- customUserProfile: [Profile](./TYPES.md#profile)?
|
||||
|
||||
StartedConnectionToGroup: Connection to channel started (when connecting via channel link)..
|
||||
- type: "startedConnectionToGroup"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
|
||||
- customUserProfile: [Profile](./TYPES.md#profile)?
|
||||
- relayResults: [[RelayConnectionResult](./TYPES.md#relayconnectionresult)]
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
- chatError: [ChatError](./TYPES.md#chaterror)
|
||||
@@ -1782,21 +1817,21 @@ Get chat previews. Supports time-based pagination — use this instead of APILis
|
||||
**Parameters**:
|
||||
- userId: int64
|
||||
- pendingConnections: bool
|
||||
- pagination: [PaginationByTime](./TYPES.md#paginationbytime)
|
||||
- pagination: [PaginationByTime](./TYPES.md#paginationbytime)?
|
||||
- query: [ChatListQuery](./TYPES.md#chatlistquery)
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/_get chats <userId>[ pcc=on] <str(pagination)> <json(query)>
|
||||
/_get chats <userId>[ pcc=on][ <str(pagination)>] <json(query)>
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/_get chats ' + userId + (pendingConnections ? ' pcc=on' : '') + ' ' + PaginationByTime.cmdString(pagination) + ' ' + JSON.stringify(query) // JavaScript
|
||||
'/_get chats ' + userId + (pendingConnections ? ' pcc=on' : '') + (pagination ? ' ' + PaginationByTime.cmdString(pagination) : '') + ' ' + JSON.stringify(query) // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ' ' + PaginationByTime_cmd_string(pagination) + ' ' + json.dumps(query) # Python
|
||||
'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ((' ' + PaginationByTime_cmd_string(pagination)) if pagination is not None else '') + ' ' + json.dumps(query) # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
@@ -1854,6 +1889,7 @@ GroupDeletedUser: User deleted group.
|
||||
- user: [User](./TYPES.md#user)
|
||||
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
|
||||
- msgSigned: bool
|
||||
- localDeletion: bool
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
@@ -2369,3 +2405,83 @@ ChatStopped: Chat stopped.
|
||||
- type: "chatStopped"
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Remote control commands
|
||||
|
||||
Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance.
|
||||
|
||||
|
||||
### ConnectRemoteCtrl
|
||||
|
||||
Connect to a remote controller using an OOB invitation link.
|
||||
|
||||
*Network usage*: interactive.
|
||||
|
||||
**Parameters**:
|
||||
- remoteInvitation: string
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/crc <remoteInvitation>
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/crc ' + remoteInvitation // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/crc ' + remoteInvitation # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
RemoteCtrlConnecting: Remote controller is connecting..
|
||||
- type: "remoteCtrlConnecting"
|
||||
- remoteCtrl_: [RemoteCtrlInfo](./TYPES.md#remotectrlinfo)?
|
||||
- ctrlAppInfo: [CtrlAppInfo](./TYPES.md#ctrlappinfo)
|
||||
- appVersion: string
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
- chatError: [ChatError](./TYPES.md#chaterror)
|
||||
|
||||
---
|
||||
|
||||
|
||||
### VerifyRemoteCtrlSession
|
||||
|
||||
Verify the remote controller session code to complete the connection.
|
||||
|
||||
*Network usage*: no.
|
||||
|
||||
**Parameters**:
|
||||
- sessionCode: string
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/verify remote ctrl <sessionCode>
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/verify remote ctrl ' + sessionCode // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/verify remote ctrl ' + sessionCode # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
RemoteCtrlConnected: Remote controller session connected..
|
||||
- type: "remoteCtrlConnected"
|
||||
- remoteCtrl: [RemoteCtrlInfo](./TYPES.md#remotectrlinfo)
|
||||
- compression: bool
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
- chatError: [ChatError](./TYPES.md#chaterror)
|
||||
|
||||
---
|
||||
|
||||
@@ -72,6 +72,10 @@ This file is generated automatically.
|
||||
- [ServiceRequest](#servicerequest)
|
||||
- [ServiceReplySent](#servicereplysent)
|
||||
|
||||
[Remote control events](#remote-control-events)
|
||||
- [RemoteCtrlSessionCode](#remotectrlsessioncode)
|
||||
- [RemoteCtrlStopped](#remotectrlstopped)
|
||||
|
||||
[Error events](#error-events)
|
||||
- [MessageError](#messageerror)
|
||||
- [ChatError](#chaterror)
|
||||
@@ -793,6 +797,37 @@ Correlate `connectionId` with the connection ID from the response to [APISendSer
|
||||
---
|
||||
|
||||
|
||||
## Remote control events
|
||||
|
||||
Bots that act as remote control hosts receive these events during the remote control session lifecycle.
|
||||
|
||||
|
||||
### RemoteCtrlSessionCode
|
||||
|
||||
Remote controller session code ready for verification.
|
||||
|
||||
Use [VerifyRemoteCtrlSession](./COMMANDS.md#verifyremotectrlsession) to complete the connection.
|
||||
|
||||
**Record type**:
|
||||
- type: "remoteCtrlSessionCode"
|
||||
- remoteCtrl_: [RemoteCtrlInfo](./TYPES.md#remotectrlinfo)?
|
||||
- sessionCode: string
|
||||
|
||||
---
|
||||
|
||||
|
||||
### RemoteCtrlStopped
|
||||
|
||||
Remote controller session stopped.
|
||||
|
||||
**Record type**:
|
||||
- type: "remoteCtrlStopped"
|
||||
- rcsState: [RemoteCtrlSessionState](./TYPES.md#remotectrlsessionstate)
|
||||
- rcStopReason: [RemoteCtrlStopReason](./TYPES.md#remotectrlstopreason)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Error events
|
||||
|
||||
Bots may log these events for debugging. There will be many error events - this does NOT indicate a malfunction - e.g., they may happen because of bad network connectivity, or because messages may be delivered to deleted chats for a short period of time (they will be ignored).
|
||||
|
||||
+118
-2
@@ -10,6 +10,7 @@ This file is generated automatically.
|
||||
- [AgentCryptoError](#agentcryptoerror)
|
||||
- [AgentErrorType](#agenterrortype)
|
||||
- [AgentServiceError](#agentserviceerror)
|
||||
- [AppVersionRange](#appversionrange)
|
||||
- [AutoAccept](#autoaccept)
|
||||
- [BadgeInfo](#badgeinfo)
|
||||
- [BadgeProof](#badgeproof)
|
||||
@@ -77,6 +78,7 @@ This file is generated automatically.
|
||||
- [CreatedConnLink](#createdconnlink)
|
||||
- [CryptoFile](#cryptofile)
|
||||
- [CryptoFileArgs](#cryptofileargs)
|
||||
- [CtrlAppInfo](#ctrlappinfo)
|
||||
- [DroppedMsg](#droppedmsg)
|
||||
- [E2EInfo](#e2einfo)
|
||||
- [ErrorType](#errortype)
|
||||
@@ -85,6 +87,7 @@ This file is generated automatically.
|
||||
- [FileError](#fileerror)
|
||||
- [FileErrorType](#fileerrortype)
|
||||
- [FileInvitation](#fileinvitation)
|
||||
- [FileProhibited](#fileprohibited)
|
||||
- [FileProtocol](#fileprotocol)
|
||||
- [FileStatus](#filestatus)
|
||||
- [FileTransferMeta](#filetransfermeta)
|
||||
@@ -158,6 +161,7 @@ This file is generated automatically.
|
||||
- [ProxyError](#proxyerror)
|
||||
- [PublicGroupAccess](#publicgroupaccess)
|
||||
- [PublicGroupData](#publicgroupdata)
|
||||
- [PublicGroupKeys](#publicgroupkeys)
|
||||
- [PublicGroupProfile](#publicgroupprofile)
|
||||
- [RCErrorType](#rcerrortype)
|
||||
- [RatchetSyncState](#ratchetsyncstate)
|
||||
@@ -169,8 +173,12 @@ This file is generated automatically.
|
||||
- [RcvGroupEvent](#rcvgroupevent)
|
||||
- [RcvMsgError](#rcvmsgerror)
|
||||
- [RelayCapabilities](#relaycapabilities)
|
||||
- [RelayConnectionResult](#relayconnectionresult)
|
||||
- [RelayProfile](#relayprofile)
|
||||
- [RelayStatus](#relaystatus)
|
||||
- [RemoteCtrlInfo](#remotectrlinfo)
|
||||
- [RemoteCtrlSessionState](#remotectrlsessionstate)
|
||||
- [RemoteCtrlStopReason](#remotectrlstopreason)
|
||||
- [ReportReason](#reportreason)
|
||||
- [RoleGroupPreference](#rolegrouppreference)
|
||||
- [SMPAgentError](#smpagenterror)
|
||||
@@ -385,6 +393,17 @@ BadSignature:
|
||||
- type: "badSignature"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## AppVersionRange
|
||||
|
||||
Remote controller app version range (min and max as version strings).
|
||||
|
||||
**Record type**:
|
||||
- minVersion: string
|
||||
- maxVersion: string
|
||||
|
||||
|
||||
---
|
||||
|
||||
## AutoAccept
|
||||
@@ -748,6 +767,7 @@ LocalRcv:
|
||||
- fileStatus: [CIFileStatus](#cifilestatus)
|
||||
- fileProtocol: [FileProtocol](#fileprotocol)
|
||||
- fileExpires: UTCTime?
|
||||
- fileProhibited: [FileProhibited](#fileprohibited)?
|
||||
|
||||
|
||||
---
|
||||
@@ -1966,6 +1986,18 @@ connFullLink + ((' ' + connShortLink) if connShortLink is not None else '') # Py
|
||||
- fileNonce: string
|
||||
|
||||
|
||||
---
|
||||
|
||||
## CtrlAppInfo
|
||||
|
||||
Remote controller application info.
|
||||
|
||||
**Record type**:
|
||||
- appVersionRange: [AppVersionRange](#appversionrange)
|
||||
- deviceName: string
|
||||
- compression: bool
|
||||
|
||||
|
||||
---
|
||||
|
||||
## DroppedMsg
|
||||
@@ -2125,6 +2157,16 @@ NO_FILE:
|
||||
- fileConnReq: string?
|
||||
- fileInline: [InlineFileMode](#inlinefilemode)?
|
||||
- fileDescr: [FileDescr](#filedescr)?
|
||||
- fileBadge: [BadgeProof](#badgeproof)?
|
||||
|
||||
|
||||
---
|
||||
|
||||
## FileProhibited
|
||||
|
||||
**Record type**:
|
||||
- maxSize: int64
|
||||
- badgeStatus: [BadgeStatus](#badgestatus)?
|
||||
|
||||
|
||||
---
|
||||
@@ -2404,8 +2446,7 @@ MemberSupport:
|
||||
## GroupKeys
|
||||
|
||||
**Record type**:
|
||||
- publicGroupId: string
|
||||
- groupRootKey: [GroupRootKey](#grouprootkey)
|
||||
- publicGroupKeys: [PublicGroupKeys](#publicgroupkeys)?
|
||||
- memberPrivKey: string
|
||||
|
||||
|
||||
@@ -3274,6 +3315,15 @@ NO_SESSION:
|
||||
- publicMemberCount: int64
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PublicGroupKeys
|
||||
|
||||
**Record type**:
|
||||
- publicGroupId: string
|
||||
- groupRootKey: [GroupRootKey](#grouprootkey)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PublicGroupProfile
|
||||
@@ -3442,6 +3492,7 @@ Cancelled:
|
||||
- fileId: int64
|
||||
- xftpRcvFile: [XFTPRcvFile](#xftprcvfile)?
|
||||
- fileInvitation: [FileInvitation](#fileinvitation)
|
||||
- fileProhibited: [FileProhibited](#fileprohibited)?
|
||||
- fileStatus: [RcvFileStatus](#rcvfilestatus)
|
||||
- fileType: [FileType](#filetype)
|
||||
- rcvFileInline: [InlineFileMode](#inlinefilemode)?
|
||||
@@ -3549,6 +3600,15 @@ ParseError:
|
||||
- webDomain: string?
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RelayConnectionResult
|
||||
|
||||
**Record type**:
|
||||
- relayMember: [GroupMember](#groupmember)
|
||||
- relayError: [ChatError](#chaterror)?
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RelayProfile
|
||||
@@ -3574,6 +3634,62 @@ ParseError:
|
||||
- "rejected"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RemoteCtrlInfo
|
||||
|
||||
**Record type**:
|
||||
- remoteCtrlId: int64
|
||||
- ctrlDeviceName: string
|
||||
- sessionState: [RemoteCtrlSessionState](#remotectrlsessionstate)?
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RemoteCtrlSessionState
|
||||
|
||||
**Discriminated union type**:
|
||||
|
||||
Starting:
|
||||
- type: "starting"
|
||||
|
||||
Searching:
|
||||
- type: "searching"
|
||||
|
||||
Connecting:
|
||||
- type: "connecting"
|
||||
|
||||
PendingConfirmation:
|
||||
- type: "pendingConfirmation"
|
||||
- sessionCode: string
|
||||
|
||||
Connected:
|
||||
- type: "connected"
|
||||
- sessionCode: string
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RemoteCtrlStopReason
|
||||
|
||||
**Discriminated union type**:
|
||||
|
||||
DiscoveryFailed:
|
||||
- type: "discoveryFailed"
|
||||
- chatError: [ChatError](#chaterror)
|
||||
|
||||
ConnectionFailed:
|
||||
- type: "connectionFailed"
|
||||
- chatError: [ChatError](#chaterror)
|
||||
|
||||
SetupFailed:
|
||||
- type: "setupFailed"
|
||||
- chatError: [ChatError](#chaterror)
|
||||
|
||||
Disconnected:
|
||||
- type: "disconnected"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ReportReason
|
||||
|
||||
@@ -80,7 +80,7 @@ chatCommandsDocsData =
|
||||
[ ("APICreateMyAddress", ["server_"], "Create bot address.", ["CRUserContactLinkCreated", "CRChatCmdError"], [], Just UNInteractive, "/_address " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing),
|
||||
("APIDeleteMyAddress", [], "Delete bot address.", ["CRUserContactLinkDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete_address " <> Param "userId"),
|
||||
("APIShowMyAddress", [], "Get bot address and settings.", ["CRUserContactLink", "CRChatCmdError"], [], Nothing, "/_show_address " <> Param "userId"),
|
||||
("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"),
|
||||
("APISetProfileAddress", [], "Add address to bot profile.", ["CRUserProfileUpdated", "CRUserProfileNoChange", "CRChatCmdError"], [], Just UNInteractive, "/_profile_address " <> Param "userId" <> " " <> OnOff "enable"),
|
||||
("APISetAddressSettings", [], "Set bot address settings.", ["CRUserContactLinkUpdated", "CRChatCmdError"], [], Just UNInteractive, "/_address_settings " <> Param "userId" <> OnOffParam "pq_ratchet" "pqRatchet" Nothing <> " " <> Json "settings")
|
||||
]
|
||||
),
|
||||
@@ -98,7 +98,7 @@ chatCommandsDocsData =
|
||||
("APIDeleteChatItem", [], "Delete message.", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete item " <> Param "chatRef" <> " " <> Join ',' "chatItemIds" <> " " <> Param "deleteMode"),
|
||||
("APIDeleteMemberChatItem", [], "Moderate message. Requires Moderator role (and higher than message author's).", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete member item #" <> Param "groupId" <> " " <> Join ',' "chatItemIds"),
|
||||
("APIChatItemReaction", [], "Add/remove message reaction.", ["CRChatItemReaction", "CRChatCmdError"], [], Just UNBackground, "/_reaction " <> Param "chatRef" <> " " <> Param "chatItemId" <> " " <> OnOff "add" <> " " <> Json "reaction"),
|
||||
("APIShareMyAddress", [], "Share user address card", ["CRChatMsgContent"], [], Nothing, "/_share address" <> Param "toSendRef"),
|
||||
("APIShareMyAddress", [], "Share user address card", ["CRChatMsgContent"], [], Nothing, "/_share address " <> Param "toSendRef"),
|
||||
("APIShareChatMsgContent", [], "Share channel address", ["CRChatMsgContent"], [], Nothing, "/_share chat content " <> Param "shareChatRef" <> " " <> Param "toSendRef")
|
||||
]
|
||||
),
|
||||
@@ -141,7 +141,7 @@ chatCommandsDocsData =
|
||||
-- `Maybe` in `connectTarget :: Maybe ConnectTarget` is used to signal parse failure to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers.
|
||||
("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link or name.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectTarget"),
|
||||
("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"),
|
||||
("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"),
|
||||
("Connect", [], "Connect via SimpleX link or name as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRConnectionPlan", "CRSentInvitationToContact", "CRStartedConnectionToContact", "CRStartedConnectionToGroup", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connTarget_"),
|
||||
("APIAcceptContact", ["incognito"], "Accept contact request.", ["CRAcceptingContactRequest", "CRChatCmdError"], [], Just UNInteractive, "/_accept " <> Param "contactReqId"),
|
||||
("APIRejectContact", [], "Reject contact request. The user who sent the request is **not notified**.", ["CRContactRequestRejected", "CRChatCmdError"], [], Nothing, "/_reject " <> Param "contactReqId")
|
||||
]
|
||||
@@ -150,7 +150,7 @@ chatCommandsDocsData =
|
||||
"Commands to list and delete conversations.",
|
||||
[ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"),
|
||||
("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"),
|
||||
("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> " " <> Param "pagination" <> " " <> Json "query"),
|
||||
("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> Optional "" (" " <> Param "$0") "pagination" <> " " <> Json "query"),
|
||||
("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"),
|
||||
("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"),
|
||||
("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"),
|
||||
@@ -202,6 +202,12 @@ chatCommandsDocsData =
|
||||
[ ("StartChat", [], "Start chat controller.", ["CRChatStarted", "CRChatRunning"], [], Nothing, "/_start" <> OnOffParam "main" "mainApp" Nothing <> OnOffParam "snd_files" "enableSndFiles" (Just True) <> OnOffParam "service_requests" "serviceRequests" (Just False)),
|
||||
("APIStopChat", [], "Stop chat controller.", ["CRChatStopped"], [], Nothing, "/_stop")
|
||||
]
|
||||
),
|
||||
( "Remote control commands",
|
||||
"Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance.",
|
||||
[ ("ConnectRemoteCtrl", [], "Connect to a remote controller using an OOB invitation link.", ["CRRemoteCtrlConnecting", "CRChatCmdError"], [], Just UNInteractive, "/crc " <> Param "remoteInvitation"),
|
||||
("VerifyRemoteCtrlSession", [], "Verify the remote controller session code to complete the connection.", ["CRRemoteCtrlConnected", "CRChatCmdError"], [], Nothing, "/verify remote ctrl " <> Param "sessionCode")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -455,7 +461,6 @@ undocumentedCommands =
|
||||
"APIVerifyToken",
|
||||
"CheckChatRunning",
|
||||
"ConfirmRemoteCtrl",
|
||||
"ConnectRemoteCtrl",
|
||||
"CustomChatCommand",
|
||||
"DebugEvent",
|
||||
"DebugLocks",
|
||||
@@ -502,6 +507,5 @@ undocumentedCommands =
|
||||
"SwitchRemoteHost",
|
||||
"TestChatRelay",
|
||||
"TestProtoServer",
|
||||
"TestStorageEncryption",
|
||||
"VerifyRemoteCtrlSession"
|
||||
"TestStorageEncryption"
|
||||
]
|
||||
|
||||
@@ -152,6 +152,13 @@ chatEventsDocsData =
|
||||
],
|
||||
[]
|
||||
),
|
||||
( "Remote control events",
|
||||
"Bots that act as remote control hosts receive these events during the remote control session lifecycle.",
|
||||
[ ("CEvtRemoteCtrlSessionCode", "Remote controller session code ready for verification.\n\nUse [VerifyRemoteCtrlSession](./COMMANDS.md#verifyremotectrlsession) to complete the connection."),
|
||||
("CEvtRemoteCtrlStopped", "Remote controller session stopped.")
|
||||
],
|
||||
[]
|
||||
),
|
||||
( "Error events",
|
||||
"Bots may log these events for debugging. \
|
||||
\There will be many error events - this does NOT indicate a malfunction - \
|
||||
@@ -205,8 +212,6 @@ undocumentedEvents =
|
||||
"CEvtRcvFileProgressXFTP",
|
||||
"CEvtRcvStandaloneFileComplete",
|
||||
"CEvtRemoteCtrlFound",
|
||||
"CEvtRemoteCtrlSessionCode",
|
||||
"CEvtRemoteCtrlStopped",
|
||||
"CEvtRemoteHostConnected",
|
||||
"CEvtRemoteHostSessionCode",
|
||||
"CEvtRemoteHostStopped",
|
||||
|
||||
@@ -88,11 +88,16 @@ chatResponsesDocsData =
|
||||
("CRRcvFileAccepted", "File accepted to be received"),
|
||||
("CRRcvFileAcceptedSndCancelled", "File accepted, but no longer sent"),
|
||||
("CRRcvFileCancelled", "Cancelled receiving file"),
|
||||
("CRRemoteCtrlConnected", "Remote controller session connected."),
|
||||
("CRRemoteCtrlConnecting", "Remote controller is connecting."),
|
||||
("CRSentConfirmation", "Confirmation sent to one-time invitation"),
|
||||
("CRSentGroupInvitation", "Group invitation sent"),
|
||||
("CRSentInvitation", "Invitation sent to contact address"),
|
||||
("CRSentInvitationToContact", "Invitation sent to contact (when connecting via SimpleX name to a known contact address)."),
|
||||
("CRServiceReplyAccepted", "Service reply accepted for delivery. `connectionId` correlates the reply delivery event."),
|
||||
("CRSndFileCancelled", "Cancelled sending file"),
|
||||
("CRStartedConnectionToContact", "Connection to contact started (when connecting via prepared contact)."),
|
||||
("CRStartedConnectionToGroup", "Connection to channel started (when connecting via channel link)."),
|
||||
("CRUserAcceptedGroupSent", "User accepted group invitation"),
|
||||
("CRUserContactLink", "User contact address"),
|
||||
("CRUserContactLinkCreated", "User contact address created"),
|
||||
@@ -190,13 +195,10 @@ undocumentedResponses =
|
||||
"CRQueueInfo",
|
||||
"CRRcvStandaloneFileCreated",
|
||||
"CRReactionMembers",
|
||||
"CRRemoteCtrlConnected",
|
||||
"CRRemoteCtrlConnecting",
|
||||
"CRRemoteCtrlList",
|
||||
"CRRemoteFileStored",
|
||||
"CRRemoteHostList",
|
||||
"CRRemoteHostStarted",
|
||||
"CRSentInvitationToContact",
|
||||
"CRServerOperatorConditions",
|
||||
"CRServerTestResult",
|
||||
"CRServiceResponse",
|
||||
@@ -204,8 +206,6 @@ undocumentedResponses =
|
||||
"CRSndStandaloneFileCreated",
|
||||
"CRSQLResult",
|
||||
"CRStandaloneFileInfo",
|
||||
"CRStartedConnectionToContact",
|
||||
"CRStartedConnectionToGroup",
|
||||
"CRTagsUpdated",
|
||||
"CRUsageConditions",
|
||||
"CRUserPrivacy",
|
||||
|
||||
@@ -49,6 +49,8 @@ import Simplex.Messaging.Parsers (dropPrefix, fstToLower)
|
||||
import Simplex.Messaging.Protocol (BlockingInfo (..), BlockingReason (..), CommandError (..), ErrorType (..), NameErrorType (..), NetworkError (..), ProxyError (..))
|
||||
import Simplex.Messaging.Protocol.Types (ClientNotice (..))
|
||||
import Simplex.Messaging.Transport
|
||||
import Simplex.Chat.Remote.AppVersion (AppVersion, AppVersionRange)
|
||||
import Simplex.Chat.Remote.Types (CtrlAppInfo (..))
|
||||
import Simplex.RemoteControl.Types
|
||||
import System.Console.ANSI.Types (Color (..))
|
||||
|
||||
@@ -211,6 +213,7 @@ chatTypesDocsData =
|
||||
(sti @AgentCryptoError, STUnion, "", ["RATCHET_EARLIER", "RATCHET_SKIPPED"], "", ""), -- TODO add fields to types
|
||||
(sti @AgentErrorType, STUnion, "", [], "", ""),
|
||||
(sti @AgentServiceError, STUnion, "ASE", [], "", ""),
|
||||
(STI "AppVersionRange" [RecordTypeInfo "AppVersionRange" [FieldInfo "minVersion" (TIType (ST TString [])), FieldInfo "maxVersion" (TIType (ST TString []))]], STRecord, "", [], "", "Remote controller app version range (min and max as version strings)."),
|
||||
(sti @AutoAccept, STRecord, "", [], "", ""),
|
||||
(sti @BadgeProof, STRecord, "", [], "", ""),
|
||||
(sti @BlockingInfo, STRecord, "", [], "", ""),
|
||||
@@ -243,6 +246,7 @@ chatTypesDocsData =
|
||||
(sti @CIReactionCount, STRecord, "", [], "", ""),
|
||||
(sti @CITimed, STRecord, "", [], "", ""),
|
||||
(sti @ClientNotice, STRecord, "", [], "", ""),
|
||||
(sti @CtrlAppInfo, STRecord, "", [], "", "Remote controller application info."),
|
||||
(sti @Color, STEnum, "", [], "", ""),
|
||||
(sti @CommandError, STUnion, "", [], "", ""),
|
||||
(sti @CommandErrorType, STUnion, "", [], "", ""),
|
||||
@@ -270,6 +274,7 @@ chatTypesDocsData =
|
||||
(sti @FileError, STUnion, "FileErr", [], "", ""),
|
||||
(sti @FileErrorType, STUnion, "", [], "", ""),
|
||||
(sti @FileInvitation, STRecord, "", [], "", ""),
|
||||
(sti @FileProhibited, STRecord, "", [], "", ""),
|
||||
(sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""),
|
||||
(sti @FileStatus, STEnum, "FS", [], "", ""),
|
||||
(sti @FileTransferMeta, STRecord, "", [], "", ""),
|
||||
@@ -342,6 +347,7 @@ chatTypesDocsData =
|
||||
(sti @ProxyError, STUnion, "", [], "", ""),
|
||||
(sti @PublicGroupAccess, STRecord, "", [], "", ""),
|
||||
(sti @PublicGroupData, STRecord, "", [], "", ""),
|
||||
(sti @PublicGroupKeys, STRecord, "", [], "", ""),
|
||||
(sti @PublicGroupProfile, STRecord, "", [], "", ""),
|
||||
(sti @RatchetSyncState, STEnum, "RS", [], "", ""),
|
||||
(sti @RCErrorType, STUnion, "RCE", [], "", ""),
|
||||
@@ -353,8 +359,12 @@ chatTypesDocsData =
|
||||
(sti @RcvGroupEvent, STUnion, "RGE", [], "", ""),
|
||||
(sti @RcvMsgError, STUnion, "RME", [], "", ""),
|
||||
(sti @RelayCapabilities, STRecord, "", [], "", ""),
|
||||
(sti @RelayConnectionResult, STRecord, "", [], "", ""),
|
||||
(sti @RelayProfile, STRecord, "", [], "", ""),
|
||||
(sti @RelayStatus, STEnum, "RS", [], "", ""),
|
||||
(sti @RemoteCtrlInfo, STRecord, "", [], "", ""),
|
||||
(sti @RemoteCtrlSessionState, STUnion, "RCS", [], "", ""),
|
||||
(sti @RemoteCtrlStopReason, STUnion, "RCSR", [], "", ""),
|
||||
(sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""),
|
||||
(sti @RoleGroupPreference, STRecord, "", [], "", ""),
|
||||
(sti @SecurityCode, STRecord, "", [], "", ""),
|
||||
@@ -470,6 +480,7 @@ deriving instance Generic CIMentionMember
|
||||
deriving instance Generic CIReactionCount
|
||||
deriving instance Generic CITimed
|
||||
deriving instance Generic ClientNotice
|
||||
deriving instance Generic CtrlAppInfo
|
||||
deriving instance Generic Color
|
||||
deriving instance Generic CommandError
|
||||
deriving instance Generic CommandErrorType
|
||||
@@ -497,6 +508,7 @@ deriving instance Generic FileDescr
|
||||
deriving instance Generic FileError
|
||||
deriving instance Generic FileErrorType
|
||||
deriving instance Generic FileInvitation
|
||||
deriving instance Generic FileProhibited
|
||||
deriving instance Generic FileProtocol
|
||||
deriving instance Generic FileStatus
|
||||
deriving instance Generic FileTransferMeta
|
||||
@@ -576,6 +588,7 @@ deriving instance Generic ProxyClientError
|
||||
deriving instance Generic ProxyError
|
||||
deriving instance Generic PublicGroupAccess
|
||||
deriving instance Generic PublicGroupData
|
||||
deriving instance Generic PublicGroupKeys
|
||||
deriving instance Generic PublicGroupProfile
|
||||
deriving instance Generic RatchetSyncState
|
||||
deriving instance Generic RCErrorType
|
||||
@@ -587,8 +600,12 @@ deriving instance Generic RcvFileTransfer
|
||||
deriving instance Generic RcvGroupEvent
|
||||
deriving instance Generic RcvMsgError
|
||||
deriving instance Generic RelayCapabilities
|
||||
deriving instance Generic RelayConnectionResult
|
||||
deriving instance Generic RelayProfile
|
||||
deriving instance Generic RelayStatus
|
||||
deriving instance Generic RemoteCtrlInfo
|
||||
deriving instance Generic RemoteCtrlSessionState
|
||||
deriving instance Generic RemoteCtrlStopReason
|
||||
deriving instance Generic ReportReason
|
||||
deriving instance Generic SecurityCode
|
||||
deriving instance Generic SimplexDomain
|
||||
|
||||
@@ -200,6 +200,7 @@ toTypeInfo tr =
|
||||
"AgentInvId",
|
||||
"AgentRcvFileId",
|
||||
"AgentSndFileId",
|
||||
"AppVersion",
|
||||
"BadgeMasterKey",
|
||||
"B64UrlByteString",
|
||||
"BBSProof",
|
||||
@@ -219,6 +220,7 @@ toTypeInfo tr =
|
||||
"ProofPresHeader",
|
||||
"PublicKey",
|
||||
"ProtocolServer",
|
||||
"RCSignedInvitation",
|
||||
"SbKey",
|
||||
"SharedMsgId",
|
||||
"Signature",
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 2d4b40e10475fd2d09c76c590df89f384ac45c85
|
||||
tag: a00e225d74dfa03aba2293530416f69c55373bed
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface APISetProfileAddress {
|
||||
}
|
||||
|
||||
export namespace APISetProfileAddress {
|
||||
export type Response = CR.UserProfileUpdated | CR.ChatCmdError
|
||||
export type Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: APISetProfileAddress): string {
|
||||
return '/_profile_address ' + self.userId + ' ' + (self.enable ? 'on' : 'off')
|
||||
@@ -178,7 +178,7 @@ export namespace APIShareMyAddress {
|
||||
export type Response = CR.ChatMsgContent
|
||||
|
||||
export function cmdString(self: APIShareMyAddress): string {
|
||||
return '/_share address' + T.ChatRef.cmdString(self.toSendRef)
|
||||
return '/_share address ' + T.ChatRef.cmdString(self.toSendRef)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +582,15 @@ export interface Connect {
|
||||
}
|
||||
|
||||
export namespace Connect {
|
||||
export type Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError
|
||||
export type Response =
|
||||
| CR.SentConfirmation
|
||||
| CR.ContactAlreadyExists
|
||||
| CR.SentInvitation
|
||||
| CR.ConnectionPlan
|
||||
| CR.SentInvitationToContact
|
||||
| CR.StartedConnectionToContact
|
||||
| CR.StartedConnectionToGroup
|
||||
| CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: Connect): string {
|
||||
return '/connect' + (self.connTarget_ ? ' ' + self.connTarget_ : '')
|
||||
@@ -656,7 +664,7 @@ export namespace APIListGroups {
|
||||
export interface APIGetChats {
|
||||
userId: number // int64
|
||||
pendingConnections: boolean
|
||||
pagination: T.PaginationByTime
|
||||
pagination?: T.PaginationByTime
|
||||
query: T.ChatListQuery
|
||||
}
|
||||
|
||||
@@ -664,7 +672,7 @@ export namespace APIGetChats {
|
||||
export type Response = CR.ApiChats | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: APIGetChats): string {
|
||||
return '/_get chats ' + self.userId + (self.pendingConnections ? ' pcc=on' : '') + ' ' + T.PaginationByTime.cmdString(self.pagination) + ' ' + JSON.stringify(self.query)
|
||||
return '/_get chats ' + self.userId + (self.pendingConnections ? ' pcc=on' : '') + (self.pagination ? ' ' + T.PaginationByTime.cmdString(self.pagination) : '') + ' ' + JSON.stringify(self.query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,3 +905,34 @@ export namespace APIStopChat {
|
||||
return '/_stop'
|
||||
}
|
||||
}
|
||||
|
||||
// Remote control commands
|
||||
// Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance.
|
||||
|
||||
// Connect to a remote controller using an OOB invitation link.
|
||||
// Network usage: interactive.
|
||||
export interface ConnectRemoteCtrl {
|
||||
remoteInvitation: string
|
||||
}
|
||||
|
||||
export namespace ConnectRemoteCtrl {
|
||||
export type Response = CR.RemoteCtrlConnecting | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: ConnectRemoteCtrl): string {
|
||||
return '/crc ' + self.remoteInvitation
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the remote controller session code to complete the connection.
|
||||
// Network usage: no.
|
||||
export interface VerifyRemoteCtrlSession {
|
||||
sessionCode: string
|
||||
}
|
||||
|
||||
export namespace VerifyRemoteCtrlSession {
|
||||
export type Response = CR.RemoteCtrlConnected | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: VerifyRemoteCtrlSession): string {
|
||||
return '/verify remote ctrl ' + self.sessionCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ export type ChatEvent =
|
||||
| CEvt.SubscriptionStatus
|
||||
| CEvt.ServiceRequest
|
||||
| CEvt.ServiceReplySent
|
||||
| CEvt.RemoteCtrlSessionCode
|
||||
| CEvt.RemoteCtrlStopped
|
||||
| CEvt.MessageError
|
||||
| CEvt.ChatError
|
||||
| CEvt.ChatErrors
|
||||
@@ -106,6 +108,8 @@ export namespace CEvt {
|
||||
| "subscriptionStatus"
|
||||
| "serviceRequest"
|
||||
| "serviceReplySent"
|
||||
| "remoteCtrlSessionCode"
|
||||
| "remoteCtrlStopped"
|
||||
| "messageError"
|
||||
| "chatError"
|
||||
| "chatErrors"
|
||||
@@ -471,6 +475,18 @@ export namespace CEvt {
|
||||
connectionId: string
|
||||
}
|
||||
|
||||
export interface RemoteCtrlSessionCode extends Interface {
|
||||
type: "remoteCtrlSessionCode"
|
||||
remoteCtrl_?: T.RemoteCtrlInfo
|
||||
sessionCode: string
|
||||
}
|
||||
|
||||
export interface RemoteCtrlStopped extends Interface {
|
||||
type: "remoteCtrlStopped"
|
||||
rcsState: T.RemoteCtrlSessionState
|
||||
rcStopReason: T.RemoteCtrlStopReason
|
||||
}
|
||||
|
||||
export interface MessageError extends Interface {
|
||||
type: "messageError"
|
||||
user: T.User
|
||||
|
||||
@@ -47,11 +47,16 @@ export type ChatResponse =
|
||||
| CR.RcvFileAccepted
|
||||
| CR.RcvFileAcceptedSndCancelled
|
||||
| CR.RcvFileCancelled
|
||||
| CR.RemoteCtrlConnected
|
||||
| CR.RemoteCtrlConnecting
|
||||
| CR.SentConfirmation
|
||||
| CR.SentGroupInvitation
|
||||
| CR.SentInvitation
|
||||
| CR.SentInvitationToContact
|
||||
| CR.ServiceReplyAccepted
|
||||
| CR.SndFileCancelled
|
||||
| CR.StartedConnectionToContact
|
||||
| CR.StartedConnectionToGroup
|
||||
| CR.UserAcceptedGroupSent
|
||||
| CR.UserContactLink
|
||||
| CR.UserContactLinkCreated
|
||||
@@ -108,11 +113,16 @@ export namespace CR {
|
||||
| "rcvFileAccepted"
|
||||
| "rcvFileAcceptedSndCancelled"
|
||||
| "rcvFileCancelled"
|
||||
| "remoteCtrlConnected"
|
||||
| "remoteCtrlConnecting"
|
||||
| "sentConfirmation"
|
||||
| "sentGroupInvitation"
|
||||
| "sentInvitation"
|
||||
| "sentInvitationToContact"
|
||||
| "serviceReplyAccepted"
|
||||
| "sndFileCancelled"
|
||||
| "startedConnectionToContact"
|
||||
| "startedConnectionToGroup"
|
||||
| "userAcceptedGroupSent"
|
||||
| "userContactLink"
|
||||
| "userContactLinkCreated"
|
||||
@@ -246,6 +256,7 @@ export namespace CR {
|
||||
user: T.User
|
||||
groupInfo: T.GroupInfo
|
||||
msgSigned: boolean
|
||||
localDeletion: boolean
|
||||
}
|
||||
|
||||
export interface GroupLink extends Interface {
|
||||
@@ -406,6 +417,19 @@ export namespace CR {
|
||||
rcvFileTransfer: T.RcvFileTransfer
|
||||
}
|
||||
|
||||
export interface RemoteCtrlConnected extends Interface {
|
||||
type: "remoteCtrlConnected"
|
||||
remoteCtrl: T.RemoteCtrlInfo
|
||||
compression: boolean
|
||||
}
|
||||
|
||||
export interface RemoteCtrlConnecting extends Interface {
|
||||
type: "remoteCtrlConnecting"
|
||||
remoteCtrl_?: T.RemoteCtrlInfo
|
||||
ctrlAppInfo: T.CtrlAppInfo
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
export interface SentConfirmation extends Interface {
|
||||
type: "sentConfirmation"
|
||||
user: T.User
|
||||
@@ -428,6 +452,13 @@ export namespace CR {
|
||||
customUserProfile?: T.Profile
|
||||
}
|
||||
|
||||
export interface SentInvitationToContact extends Interface {
|
||||
type: "sentInvitationToContact"
|
||||
user: T.User
|
||||
contact: T.Contact
|
||||
customUserProfile?: T.Profile
|
||||
}
|
||||
|
||||
export interface ServiceReplyAccepted extends Interface {
|
||||
type: "serviceReplyAccepted"
|
||||
user: T.User
|
||||
@@ -442,6 +473,21 @@ export namespace CR {
|
||||
sndFileTransfers: T.SndFileTransfer[]
|
||||
}
|
||||
|
||||
export interface StartedConnectionToContact extends Interface {
|
||||
type: "startedConnectionToContact"
|
||||
user: T.User
|
||||
contact: T.Contact
|
||||
customUserProfile?: T.Profile
|
||||
}
|
||||
|
||||
export interface StartedConnectionToGroup extends Interface {
|
||||
type: "startedConnectionToGroup"
|
||||
user: T.User
|
||||
groupInfo: T.GroupInfo
|
||||
customUserProfile?: T.Profile
|
||||
relayResults: T.RelayConnectionResult[]
|
||||
}
|
||||
|
||||
export interface UserAcceptedGroupSent extends Interface {
|
||||
type: "userAcceptedGroupSent"
|
||||
user: T.User
|
||||
|
||||
@@ -223,6 +223,12 @@ export namespace AgentServiceError {
|
||||
type: "badSignature"
|
||||
}
|
||||
}
|
||||
// Remote controller app version range (min and max as version strings).
|
||||
|
||||
export interface AppVersionRange {
|
||||
minVersion: string
|
||||
maxVersion: string
|
||||
}
|
||||
|
||||
export interface AutoAccept {
|
||||
acceptIncognito: boolean
|
||||
@@ -693,6 +699,7 @@ export interface CIFile {
|
||||
fileStatus: CIFileStatus
|
||||
fileProtocol: FileProtocol
|
||||
fileExpires?: string // ISO-8601 timestamp
|
||||
fileProhibited?: FileProhibited
|
||||
}
|
||||
|
||||
export type CIFileStatus =
|
||||
@@ -2210,6 +2217,13 @@ export interface CryptoFileArgs {
|
||||
fileKey: string
|
||||
fileNonce: string
|
||||
}
|
||||
// Remote controller application info.
|
||||
|
||||
export interface CtrlAppInfo {
|
||||
appVersionRange: AppVersionRange
|
||||
deviceName: string
|
||||
compression: boolean
|
||||
}
|
||||
|
||||
export interface DroppedMsg {
|
||||
brokerTs: string // ISO-8601 timestamp
|
||||
@@ -2427,6 +2441,12 @@ export interface FileInvitation {
|
||||
fileConnReq?: string
|
||||
fileInline?: InlineFileMode
|
||||
fileDescr?: FileDescr
|
||||
fileBadge?: BadgeProof
|
||||
}
|
||||
|
||||
export interface FileProhibited {
|
||||
maxSize: number // int64
|
||||
badgeStatus?: BadgeStatus
|
||||
}
|
||||
|
||||
export enum FileProtocol {
|
||||
@@ -2700,8 +2720,7 @@ export interface GroupInfo {
|
||||
}
|
||||
|
||||
export interface GroupKeys {
|
||||
publicGroupId: string
|
||||
groupRootKey: GroupRootKey
|
||||
publicGroupKeys?: PublicGroupKeys
|
||||
memberPrivKey: string
|
||||
}
|
||||
|
||||
@@ -3548,6 +3567,11 @@ export interface PublicGroupData {
|
||||
publicMemberCount: number // int64
|
||||
}
|
||||
|
||||
export interface PublicGroupKeys {
|
||||
publicGroupId: string
|
||||
groupRootKey: GroupRootKey
|
||||
}
|
||||
|
||||
export interface PublicGroupProfile {
|
||||
groupType: GroupType
|
||||
groupLink: string
|
||||
@@ -3784,6 +3808,7 @@ export interface RcvFileTransfer {
|
||||
fileId: number // int64
|
||||
xftpRcvFile?: XFTPRcvFile
|
||||
fileInvitation: FileInvitation
|
||||
fileProhibited?: FileProhibited
|
||||
fileStatus: RcvFileStatus
|
||||
fileType: FileType
|
||||
rcvFileInline?: InlineFileMode
|
||||
@@ -3946,6 +3971,11 @@ export interface RelayCapabilities {
|
||||
webDomain?: string
|
||||
}
|
||||
|
||||
export interface RelayConnectionResult {
|
||||
relayMember: GroupMember
|
||||
relayError?: ChatError
|
||||
}
|
||||
|
||||
export interface RelayProfile {
|
||||
displayName: string
|
||||
fullName: string
|
||||
@@ -3963,6 +3993,87 @@ export enum RelayStatus {
|
||||
Rejected = "rejected",
|
||||
}
|
||||
|
||||
export interface RemoteCtrlInfo {
|
||||
remoteCtrlId: number // int64
|
||||
ctrlDeviceName: string
|
||||
sessionState?: RemoteCtrlSessionState
|
||||
}
|
||||
|
||||
export type RemoteCtrlSessionState =
|
||||
| RemoteCtrlSessionState.Starting
|
||||
| RemoteCtrlSessionState.Searching
|
||||
| RemoteCtrlSessionState.Connecting
|
||||
| RemoteCtrlSessionState.PendingConfirmation
|
||||
| RemoteCtrlSessionState.Connected
|
||||
|
||||
export namespace RemoteCtrlSessionState {
|
||||
export type Tag =
|
||||
| "starting"
|
||||
| "searching"
|
||||
| "connecting"
|
||||
| "pendingConfirmation"
|
||||
| "connected"
|
||||
|
||||
interface Interface {
|
||||
type: Tag
|
||||
}
|
||||
|
||||
export interface Starting extends Interface {
|
||||
type: "starting"
|
||||
}
|
||||
|
||||
export interface Searching extends Interface {
|
||||
type: "searching"
|
||||
}
|
||||
|
||||
export interface Connecting extends Interface {
|
||||
type: "connecting"
|
||||
}
|
||||
|
||||
export interface PendingConfirmation extends Interface {
|
||||
type: "pendingConfirmation"
|
||||
sessionCode: string
|
||||
}
|
||||
|
||||
export interface Connected extends Interface {
|
||||
type: "connected"
|
||||
sessionCode: string
|
||||
}
|
||||
}
|
||||
|
||||
export type RemoteCtrlStopReason =
|
||||
| RemoteCtrlStopReason.DiscoveryFailed
|
||||
| RemoteCtrlStopReason.ConnectionFailed
|
||||
| RemoteCtrlStopReason.SetupFailed
|
||||
| RemoteCtrlStopReason.Disconnected
|
||||
|
||||
export namespace RemoteCtrlStopReason {
|
||||
export type Tag = "discoveryFailed" | "connectionFailed" | "setupFailed" | "disconnected"
|
||||
|
||||
interface Interface {
|
||||
type: Tag
|
||||
}
|
||||
|
||||
export interface DiscoveryFailed extends Interface {
|
||||
type: "discoveryFailed"
|
||||
chatError: ChatError
|
||||
}
|
||||
|
||||
export interface ConnectionFailed extends Interface {
|
||||
type: "connectionFailed"
|
||||
chatError: ChatError
|
||||
}
|
||||
|
||||
export interface SetupFailed extends Interface {
|
||||
type: "setupFailed"
|
||||
chatError: ChatError
|
||||
}
|
||||
|
||||
export interface Disconnected extends Interface {
|
||||
type: "disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
export enum ReportReason {
|
||||
Spam = "spam",
|
||||
Content = "content",
|
||||
|
||||
@@ -56,7 +56,7 @@ class APISetProfileAddress(TypedDict):
|
||||
def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str:
|
||||
return '/_profile_address ' + str(self['userId']) + ' ' + ('on' if self['enable'] else 'off')
|
||||
|
||||
APISetProfileAddress_Response = CR.UserProfileUpdated | CR.ChatCmdError
|
||||
APISetProfileAddress_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError
|
||||
|
||||
|
||||
# Set bot address settings.
|
||||
@@ -156,7 +156,7 @@ class APIShareMyAddress(TypedDict):
|
||||
|
||||
|
||||
def APIShareMyAddress_cmd_string(self: APIShareMyAddress) -> str:
|
||||
return '/_share address' + T.ChatRef_cmd_string(self['toSendRef'])
|
||||
return '/_share address ' + T.ChatRef_cmd_string(self['toSendRef'])
|
||||
|
||||
APIShareMyAddress_Response = CR.ChatMsgContent
|
||||
|
||||
@@ -513,7 +513,16 @@ class Connect(TypedDict):
|
||||
def Connect_cmd_string(self: Connect) -> str:
|
||||
return '/connect' + ((' ' + self.get('connTarget_')) if self.get('connTarget_') is not None else '')
|
||||
|
||||
Connect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError
|
||||
Connect_Response = (
|
||||
CR.SentConfirmation
|
||||
| CR.ContactAlreadyExists
|
||||
| CR.SentInvitation
|
||||
| CR.ConnectionPlan
|
||||
| CR.SentInvitationToContact
|
||||
| CR.StartedConnectionToContact
|
||||
| CR.StartedConnectionToGroup
|
||||
| CR.ChatCmdError
|
||||
)
|
||||
|
||||
|
||||
# Accept contact request.
|
||||
@@ -575,12 +584,12 @@ APIListGroups_Response = CR.GroupsList | CR.ChatCmdError
|
||||
class APIGetChats(TypedDict):
|
||||
userId: int # int64
|
||||
pendingConnections: bool
|
||||
pagination: "T.PaginationByTime"
|
||||
pagination: NotRequired["T.PaginationByTime"]
|
||||
query: "T.ChatListQuery"
|
||||
|
||||
|
||||
def APIGetChats_cmd_string(self: APIGetChats) -> str:
|
||||
return '/_get chats ' + str(self['userId']) + (' pcc=on' if self['pendingConnections'] else '') + ' ' + T.PaginationByTime_cmd_string(self['pagination']) + ' ' + json.dumps(self['query'])
|
||||
return '/_get chats ' + str(self['userId']) + (' pcc=on' if self['pendingConnections'] else '') + ((' ' + T.PaginationByTime_cmd_string(self.get('pagination'))) if self.get('pagination') is not None else '') + ' ' + json.dumps(self['query'])
|
||||
|
||||
APIGetChats_Response = CR.ApiChats | CR.ChatCmdError
|
||||
|
||||
@@ -787,3 +796,30 @@ def APIStopChat_cmd_string(self: APIStopChat) -> str:
|
||||
|
||||
APIStopChat_Response = CR.ChatStopped
|
||||
|
||||
|
||||
# Remote control commands
|
||||
# Allows a bot to accept an incoming remote control session from a SimpleX Desktop client, giving the desktop live access to the bot's SimpleX instance.
|
||||
|
||||
# Connect to a remote controller using an OOB invitation link.
|
||||
# Network usage: interactive.
|
||||
class ConnectRemoteCtrl(TypedDict):
|
||||
remoteInvitation: str
|
||||
|
||||
|
||||
def ConnectRemoteCtrl_cmd_string(self: ConnectRemoteCtrl) -> str:
|
||||
return '/crc ' + self['remoteInvitation']
|
||||
|
||||
ConnectRemoteCtrl_Response = CR.RemoteCtrlConnecting | CR.ChatCmdError
|
||||
|
||||
|
||||
# Verify the remote controller session code to complete the connection.
|
||||
# Network usage: no.
|
||||
class VerifyRemoteCtrlSession(TypedDict):
|
||||
sessionCode: str
|
||||
|
||||
|
||||
def VerifyRemoteCtrlSession_cmd_string(self: VerifyRemoteCtrlSession) -> str:
|
||||
return '/verify remote ctrl ' + self['sessionCode']
|
||||
|
||||
VerifyRemoteCtrlSession_Response = CR.RemoteCtrlConnected | CR.ChatCmdError
|
||||
|
||||
|
||||
@@ -314,6 +314,16 @@ class ServiceReplySent(TypedDict):
|
||||
type: Literal["serviceReplySent"]
|
||||
connectionId: str
|
||||
|
||||
class RemoteCtrlSessionCode(TypedDict):
|
||||
type: Literal["remoteCtrlSessionCode"]
|
||||
remoteCtrl_: NotRequired["T.RemoteCtrlInfo"]
|
||||
sessionCode: str
|
||||
|
||||
class RemoteCtrlStopped(TypedDict):
|
||||
type: Literal["remoteCtrlStopped"]
|
||||
rcsState: "T.RemoteCtrlSessionState"
|
||||
rcStopReason: "T.RemoteCtrlStopReason"
|
||||
|
||||
class MessageError(TypedDict):
|
||||
type: Literal["messageError"]
|
||||
user: "T.User"
|
||||
@@ -377,12 +387,14 @@ ChatEvent = (
|
||||
| SubscriptionStatus
|
||||
| ServiceRequest
|
||||
| ServiceReplySent
|
||||
| RemoteCtrlSessionCode
|
||||
| RemoteCtrlStopped
|
||||
| MessageError
|
||||
| ChatError
|
||||
| ChatErrors
|
||||
)
|
||||
|
||||
ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "messageError", "chatError", "chatErrors"]
|
||||
ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "serviceRequest", "serviceReplySent", "remoteCtrlSessionCode", "remoteCtrlStopped", "messageError", "chatError", "chatErrors"]
|
||||
|
||||
|
||||
class OnEventDecorator(Protocol):
|
||||
@@ -681,6 +693,18 @@ class OnEventDecorator(Protocol):
|
||||
Callable[["ServiceReplySent"], Awaitable[None]],
|
||||
]: ...
|
||||
|
||||
@overload
|
||||
def __call__(self, event: Literal["remoteCtrlSessionCode"], /) -> Callable[
|
||||
[Callable[["RemoteCtrlSessionCode"], Awaitable[None]]],
|
||||
Callable[["RemoteCtrlSessionCode"], Awaitable[None]],
|
||||
]: ...
|
||||
|
||||
@overload
|
||||
def __call__(self, event: Literal["remoteCtrlStopped"], /) -> Callable[
|
||||
[Callable[["RemoteCtrlStopped"], Awaitable[None]]],
|
||||
Callable[["RemoteCtrlStopped"], Awaitable[None]],
|
||||
]: ...
|
||||
|
||||
@overload
|
||||
def __call__(self, event: Literal["messageError"], /) -> Callable[
|
||||
[Callable[["MessageError"], Awaitable[None]]],
|
||||
|
||||
@@ -103,6 +103,7 @@ class GroupDeletedUser(TypedDict):
|
||||
user: "T.User"
|
||||
groupInfo: "T.GroupInfo"
|
||||
msgSigned: bool
|
||||
localDeletion: bool
|
||||
|
||||
class GroupLink(TypedDict):
|
||||
type: Literal["groupLink"]
|
||||
@@ -239,6 +240,17 @@ class RcvFileCancelled(TypedDict):
|
||||
chatItem_: NotRequired["T.AChatItem"]
|
||||
rcvFileTransfer: "T.RcvFileTransfer"
|
||||
|
||||
class RemoteCtrlConnected(TypedDict):
|
||||
type: Literal["remoteCtrlConnected"]
|
||||
remoteCtrl: "T.RemoteCtrlInfo"
|
||||
compression: bool
|
||||
|
||||
class RemoteCtrlConnecting(TypedDict):
|
||||
type: Literal["remoteCtrlConnecting"]
|
||||
remoteCtrl_: NotRequired["T.RemoteCtrlInfo"]
|
||||
ctrlAppInfo: "T.CtrlAppInfo"
|
||||
appVersion: str
|
||||
|
||||
class SentConfirmation(TypedDict):
|
||||
type: Literal["sentConfirmation"]
|
||||
user: "T.User"
|
||||
@@ -258,6 +270,12 @@ class SentInvitation(TypedDict):
|
||||
connection: "T.PendingContactConnection"
|
||||
customUserProfile: NotRequired["T.Profile"]
|
||||
|
||||
class SentInvitationToContact(TypedDict):
|
||||
type: Literal["sentInvitationToContact"]
|
||||
user: "T.User"
|
||||
contact: "T.Contact"
|
||||
customUserProfile: NotRequired["T.Profile"]
|
||||
|
||||
class ServiceReplyAccepted(TypedDict):
|
||||
type: Literal["serviceReplyAccepted"]
|
||||
user: "T.User"
|
||||
@@ -270,6 +288,19 @@ class SndFileCancelled(TypedDict):
|
||||
fileTransferMeta: "T.FileTransferMeta"
|
||||
sndFileTransfers: list["T.SndFileTransfer"]
|
||||
|
||||
class StartedConnectionToContact(TypedDict):
|
||||
type: Literal["startedConnectionToContact"]
|
||||
user: "T.User"
|
||||
contact: "T.Contact"
|
||||
customUserProfile: NotRequired["T.Profile"]
|
||||
|
||||
class StartedConnectionToGroup(TypedDict):
|
||||
type: Literal["startedConnectionToGroup"]
|
||||
user: "T.User"
|
||||
groupInfo: "T.GroupInfo"
|
||||
customUserProfile: NotRequired["T.Profile"]
|
||||
relayResults: list["T.RelayConnectionResult"]
|
||||
|
||||
class UserAcceptedGroupSent(TypedDict):
|
||||
type: Literal["userAcceptedGroupSent"]
|
||||
user: "T.User"
|
||||
@@ -367,11 +398,16 @@ ChatResponse = (
|
||||
| RcvFileAccepted
|
||||
| RcvFileAcceptedSndCancelled
|
||||
| RcvFileCancelled
|
||||
| RemoteCtrlConnected
|
||||
| RemoteCtrlConnecting
|
||||
| SentConfirmation
|
||||
| SentGroupInvitation
|
||||
| SentInvitation
|
||||
| SentInvitationToContact
|
||||
| ServiceReplyAccepted
|
||||
| SndFileCancelled
|
||||
| StartedConnectionToContact
|
||||
| StartedConnectionToGroup
|
||||
| UserAcceptedGroupSent
|
||||
| UserContactLink
|
||||
| UserContactLinkCreated
|
||||
@@ -384,4 +420,4 @@ ChatResponse = (
|
||||
| ApiChats
|
||||
)
|
||||
|
||||
ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "serviceReplyAccepted", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"]
|
||||
ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "remoteCtrlConnected", "remoteCtrlConnecting", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sentInvitationToContact", "serviceReplyAccepted", "sndFileCancelled", "startedConnectionToContact", "startedConnectionToGroup", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"]
|
||||
|
||||
@@ -165,6 +165,12 @@ AgentServiceError = (
|
||||
|
||||
AgentServiceError_Tag = Literal["rejected", "timeout", "noPendingRequest", "notDRAddress", "badSignature"]
|
||||
|
||||
# Remote controller app version range (min and max as version strings).
|
||||
|
||||
class AppVersionRange(TypedDict):
|
||||
minVersion: str
|
||||
maxVersion: str
|
||||
|
||||
class AutoAccept(TypedDict):
|
||||
acceptIncognito: bool
|
||||
|
||||
@@ -482,6 +488,7 @@ class CIFile(TypedDict):
|
||||
fileStatus: "CIFileStatus"
|
||||
fileProtocol: "FileProtocol"
|
||||
fileExpires: NotRequired[str] # ISO-8601 timestamp
|
||||
fileProhibited: NotRequired["FileProhibited"]
|
||||
|
||||
class CIFileStatus_sndStored(TypedDict):
|
||||
type: Literal["sndStored"]
|
||||
@@ -1560,6 +1567,13 @@ class CryptoFileArgs(TypedDict):
|
||||
fileKey: str
|
||||
fileNonce: str
|
||||
|
||||
# Remote controller application info.
|
||||
|
||||
class CtrlAppInfo(TypedDict):
|
||||
appVersionRange: "AppVersionRange"
|
||||
deviceName: str
|
||||
compression: bool
|
||||
|
||||
class DroppedMsg(TypedDict):
|
||||
brokerTs: str # ISO-8601 timestamp
|
||||
attempts: int # int
|
||||
@@ -1712,6 +1726,11 @@ class FileInvitation(TypedDict):
|
||||
fileConnReq: NotRequired[str]
|
||||
fileInline: NotRequired["InlineFileMode"]
|
||||
fileDescr: NotRequired["FileDescr"]
|
||||
fileBadge: NotRequired["BadgeProof"]
|
||||
|
||||
class FileProhibited(TypedDict):
|
||||
maxSize: int # int64
|
||||
badgeStatus: NotRequired["BadgeStatus"]
|
||||
|
||||
FileProtocol = Literal["SMP", "XFTP", "LOCAL"]
|
||||
|
||||
@@ -1898,8 +1917,7 @@ class GroupInfo(TypedDict):
|
||||
groupDomainVerified: NotRequired[bool]
|
||||
|
||||
class GroupKeys(TypedDict):
|
||||
publicGroupId: str
|
||||
groupRootKey: "GroupRootKey"
|
||||
publicGroupKeys: NotRequired["PublicGroupKeys"]
|
||||
memberPrivKey: str
|
||||
|
||||
class GroupLink(TypedDict):
|
||||
@@ -2490,6 +2508,10 @@ class PublicGroupAccess(TypedDict):
|
||||
class PublicGroupData(TypedDict):
|
||||
publicMemberCount: int # int64
|
||||
|
||||
class PublicGroupKeys(TypedDict):
|
||||
publicGroupId: str
|
||||
groupRootKey: "GroupRootKey"
|
||||
|
||||
class PublicGroupProfile(TypedDict):
|
||||
groupType: "GroupType"
|
||||
groupLink: str
|
||||
@@ -2654,6 +2676,7 @@ class RcvFileTransfer(TypedDict):
|
||||
fileId: int # int64
|
||||
xftpRcvFile: NotRequired["XFTPRcvFile"]
|
||||
fileInvitation: "FileInvitation"
|
||||
fileProhibited: NotRequired["FileProhibited"]
|
||||
fileStatus: "RcvFileStatus"
|
||||
fileType: "FileType"
|
||||
rcvFileInline: NotRequired["InlineFileMode"]
|
||||
@@ -2767,6 +2790,10 @@ RcvMsgError_Tag = Literal["dropped", "parseError"]
|
||||
class RelayCapabilities(TypedDict):
|
||||
webDomain: NotRequired[str]
|
||||
|
||||
class RelayConnectionResult(TypedDict):
|
||||
relayMember: "GroupMember"
|
||||
relayError: NotRequired["ChatError"]
|
||||
|
||||
class RelayProfile(TypedDict):
|
||||
displayName: str
|
||||
fullName: str
|
||||
@@ -2775,6 +2802,62 @@ class RelayProfile(TypedDict):
|
||||
|
||||
RelayStatus = Literal["new", "invited", "accepted", "acknowledgedRoster", "active", "inactive", "rejected"]
|
||||
|
||||
class RemoteCtrlInfo(TypedDict):
|
||||
remoteCtrlId: int # int64
|
||||
ctrlDeviceName: str
|
||||
sessionState: NotRequired["RemoteCtrlSessionState"]
|
||||
|
||||
class RemoteCtrlSessionState_starting(TypedDict):
|
||||
type: Literal["starting"]
|
||||
|
||||
class RemoteCtrlSessionState_searching(TypedDict):
|
||||
type: Literal["searching"]
|
||||
|
||||
class RemoteCtrlSessionState_connecting(TypedDict):
|
||||
type: Literal["connecting"]
|
||||
|
||||
class RemoteCtrlSessionState_pendingConfirmation(TypedDict):
|
||||
type: Literal["pendingConfirmation"]
|
||||
sessionCode: str
|
||||
|
||||
class RemoteCtrlSessionState_connected(TypedDict):
|
||||
type: Literal["connected"]
|
||||
sessionCode: str
|
||||
|
||||
RemoteCtrlSessionState = (
|
||||
RemoteCtrlSessionState_starting
|
||||
| RemoteCtrlSessionState_searching
|
||||
| RemoteCtrlSessionState_connecting
|
||||
| RemoteCtrlSessionState_pendingConfirmation
|
||||
| RemoteCtrlSessionState_connected
|
||||
)
|
||||
|
||||
RemoteCtrlSessionState_Tag = Literal["starting", "searching", "connecting", "pendingConfirmation", "connected"]
|
||||
|
||||
class RemoteCtrlStopReason_discoveryFailed(TypedDict):
|
||||
type: Literal["discoveryFailed"]
|
||||
chatError: "ChatError"
|
||||
|
||||
class RemoteCtrlStopReason_connectionFailed(TypedDict):
|
||||
type: Literal["connectionFailed"]
|
||||
chatError: "ChatError"
|
||||
|
||||
class RemoteCtrlStopReason_setupFailed(TypedDict):
|
||||
type: Literal["setupFailed"]
|
||||
chatError: "ChatError"
|
||||
|
||||
class RemoteCtrlStopReason_disconnected(TypedDict):
|
||||
type: Literal["disconnected"]
|
||||
|
||||
RemoteCtrlStopReason = (
|
||||
RemoteCtrlStopReason_discoveryFailed
|
||||
| RemoteCtrlStopReason_connectionFailed
|
||||
| RemoteCtrlStopReason_setupFailed
|
||||
| RemoteCtrlStopReason_disconnected
|
||||
)
|
||||
|
||||
RemoteCtrlStopReason_Tag = Literal["discoveryFailed", "connectionFailed", "setupFailed", "disconnected"]
|
||||
|
||||
ReportReason = Literal["spam", "content", "community", "profile", "other"]
|
||||
|
||||
class RoleGroupPreference(TypedDict):
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# p2p group member keys - generation and distribution
|
||||
|
||||
## Goal
|
||||
|
||||
Give every member of a p2p (non-relay) group an Ed25519 signing key, and distribute each member's public key to the other members, so p2p group messages can be signed and verified. New members are keyed at join; existing members are keyed on upgrade and their keys are distributed through the existing profile-update path.
|
||||
|
||||
## Design (agreed)
|
||||
|
||||
- Own key: private in `groups.member_priv_key` (via `GroupKeys.memberPrivKey`), public in the membership's `group_members.member_pub_key`. `groupKeys = Just (GroupKeys {publicGroupKeys = Nothing, memberPrivKey})` marks a p2p member key.
|
||||
- Distribution: the public key is included in `XInfo` (and in `XContact` at join). `XInfo` is sent by the existing profile-update send (`sendGroupProfileUpdate`); the key is included whenever `XInfo` is sent, and a per-member flag records delivery to version-compatible members. One `XInfo` per send - if the profile is sent because it changed, the key is included in that message rather than a second one.
|
||||
- Version: a new chat version decides who is marked and who can read the key. A member between version 7 and the new version receives `XInfo` for the profile and ignores the unknown key field.
|
||||
- No acknowledgement in groups: the flag is set on send. A lost message means the member cannot verify until the next send re-delivers the key; whether an unverifiable claim is hidden or shown is a per-claim decision.
|
||||
|
||||
## Current state (last commit `261d09ba4`)
|
||||
|
||||
Field plumbing is done: `memberKey :: Maybe MemberKey` added to `XInfo` and `XContact`, full encode/decode, all call sites pass `Nothing`/`_`. Four `TODO [member keys]` markers remain at the fill-in points: `Commands.hs:3911` (XContact join), `Internal.hs:2489` (profile-update send `sendGroupProfileUpdate`), `Subscriber.hs:836` (join-confirmation allow), `xInfoMember` (receive/store).
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Version - `Protocol.hs`
|
||||
|
||||
- Add `groupMemberKeyVersion :: VersionChat = VersionChat 20` with a comment.
|
||||
- `currentChatVersion = VersionChat 20` (from 19).
|
||||
- Add changelog line `-- 20 - p2p group member keys for signing (2026-07-26)`.
|
||||
- No new binary-floor constant: reuse `relayWebCapVersion` (18) as the reliable binary-batch floor for partitioning signed sends (item 7). Binary parsing was added in #6597 at version 17 with no constant; 18 is the first guaranteed.
|
||||
|
||||
### 2. Schema + type + row parsing
|
||||
|
||||
- New migration `M20260726_member_key_sent.hs` (mirror `M20260720_server_roles.hs`): `ALTER TABLE group_members ADD COLUMN user_member_key_sent INTEGER NOT NULL DEFAULT 0`. Update `chat_schema.sql`.
|
||||
- `GroupMember` (`Types.hs:1119`): add `userMemberKeySent :: Bool` after `memberPubKey`.
|
||||
- `GroupMemberRow` / `MaybeGroupMemberRow` (`Groups.hs:263`): add `BoolInt` / `Maybe BoolInt` to the last tuple group, next to `member_pub_key`.
|
||||
- `toGroupMember` / `toMaybeGroupMember`: parse the column.
|
||||
- Every `SELECT` that builds a `GroupMemberRow` adds `user_member_key_sent` (shared column list - several sites; grep the existing `member_pub_key, relay_link` list).
|
||||
|
||||
### 3. Own key generation + storage (key exists before signing)
|
||||
|
||||
- New store fn `setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> IO ()`: two writes, both required - the private key to `groups.member_priv_key` (the user's own signing key for this group) and its derived public key to the user's own membership row (`group_members.member_pub_key`), as `createNewGroup:429` does.
|
||||
- New helper `ensureUserMemberKey :: User -> GroupInfo -> CM GroupInfo`: if `groupKeys` already has a key, return `gInfo` unchanged; for a p2p group with `groupKeys = Nothing`, generate an Ed25519 key, store it via `setUserMemberKey`, and return `gInfo` with `groupKeys = Just (GroupKeys {publicGroupKeys = Nothing, memberPrivKey})`. Idempotent (check-and-set in one transaction so concurrent sends cannot create two keys).
|
||||
- Generation points:
|
||||
- Create group - `APINewGroup` (`Commands.hs:2642`): generate the key and pass `Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey}` to `newGroup` (was `Nothing`). `createNewGroup` (`Groups.hs:393-430`) already stores both columns.
|
||||
- Join - in `joinContact`'s p2p-group branch (item 5): `gInfo' <- ensureUserMemberKey user gInfo`, take the public key from `gInfo'` `groupKeys` for `XContact`.
|
||||
- Send - call `ensureUserMemberKey` at the top of the send entry (`sendGroupMessages` `:2458`, `sendGroupSignedMessages` `:2464`) and thread the returned `gInfo'` to BOTH `sendGroupProfileUpdate` and `sendGroupMessages_`, so `groupMsgSigning` signs the very first message after generation - the key must not lag one message behind. This is also the lazy path for groups created before this change.
|
||||
|
||||
### 4. `sendGroupProfileUpdate` - one `XInfo` with profile and/or key (`Internal.hs:2468`)
|
||||
|
||||
Key and signing are independent of incognito status: the member key is per group and needed in every p2p group. Only the badge may depend on incognito, and that is handled by the existing profile/badge logic, not the key path. `shouldSendProfileUpdate` gates only the profile part; the key part runs regardless. Restructure `sendGroupProfileUpdate` so a single `XInfo` serves both purposes (never two messages), for non-relay groups, using `gInfo'` from `ensureUserMemberKey`:
|
||||
|
||||
- `profileMembers = if shouldSendProfileUpdate then filter (\`supportsVersion\` memberProfileUpdateVersion) members else []` (unchanged trigger; still skips incognito, scope, asGroup).
|
||||
- `keyMembers` = members with `supportsVersion groupMemberKeyVersion` and `not (userMemberKeySent m)` - runs regardless of incognito.
|
||||
- recipients = union of the two.
|
||||
- Send one `XInfo profile (Just ownKey)` to recipients via `sendGroupMessages_` (`:2500`), which returns the `GroupSndResult` needed for marking - `sendGroupMessage'` (`:2374`) discards it (the `_` at `:2377`), so the current `sendGroupProfileUpdate` send call must change. `profile` and its badge are the existing profile logic (unchanged); `ownKey = MemberKey (C.publicKey memberPrivKey)` from `gInfo'` `groupKeys`.
|
||||
- After send, from that `GroupSndResult`: set `userMemberKeySent = True` for a key recipient whose `sentTo` delivery result (the third tuple element, `:2495`) is `Right`, or that is in `pending` or `forwarded` (enqueued, stored for delivery on connect, or forwarded). A `sentTo` `Left` (enqueue failure) stays `False`. `updateUserMemberProfileSentAt` only when `shouldSendProfileUpdate`.
|
||||
- Retry stays but is uncapped: any `False` member is re-included on the next send. No cap is needed because `memberSendAction` (`Internal.hs:2608`) returns `Nothing` for a disabled/deleted/failed/rejected connection, so `addMember` (`:2542`) skips it - re-inclusion re-filters it in memory, it is never actually re-sent. The only un-marked-but-attempted case is a `sentTo` failure on a *ready* connection (a rare enqueue error), which retries next send and fails identically for the content message; a truly broken connection transitions to disabled/failed and is then skipped. So retry is cheap and self-limiting. `user_member_key_sent` is a plain boolean.
|
||||
- New store fn `setMembersMemberKeySent :: DB.Connection -> [GroupMemberId] -> IO ()`.
|
||||
- Relay groups keep current behaviour (no key here; key comes from the roster).
|
||||
|
||||
The member list is already in memory and `userMemberKeySent` is a field on the record, so both filters are in-memory with no extra query.
|
||||
|
||||
### 5. Fill the TODO send points
|
||||
|
||||
- `joinContact` (`Commands.hs:3900`): the key belongs only in the `Just (Just gInfo) | not (useRelays' gInfo)` case (p2p group join). Split that out of the current `_` branch: `gInfo' <- ensureUserMemberKey user gInfo`, then `XContact profileToSend (Just ownKey) (Just xContactId) welcomeSharedMsgId msg_`. The `Just Nothing` (unknown group) and `Nothing` (direct contact) cases keep `XContact ... Nothing ...`. `XContact` is `encodeConnInfoPQ` (JSON), so this delivery is **unsigned** - the initial trust-on-first-use key. The membership row exists in `gInfo` here, so `setUserMemberKey` writes `member_pub_key` (#5 confirmed).
|
||||
- `Subscriber.hs:836` (joiner's allow-reply to the host): `XInfo profileToSend (Just ownKey)`, **signed** with the joiner's key when the host version allows (item 6). `XInfo` is `requiresSignature`, so it is signed like any other `XInfo`; this gives the host a signed confirmation of the joiner's key at join.
|
||||
- `Subscriber.hs:1626` (host accepting the join): pass the parsed `XContact.memberKey` to `acceptGroupJoinRequestAsync` instead of `Nothing`; it flows to `createJoiningMember` (`Groups.hs:2070`, `:2112`), which stores `member_pub_key` (unsigned TOFU).
|
||||
- Host key to the joiner: `XGrpLinkMem` (`Protocol.hs:503`, currently `Profile` only) needs a `Maybe MemberKey` field added, like the commit added to `XInfo`/`XContact`. The host already sends it during the join in `sendXGrpLinkMem` (`Subscriber.hs:974`), fired on the joiner's `CON` (`:958`); include the host's key, and store it in `xGrpLinkMem` (`:2760`). This is the host->joiner counterpart of the joiner's `XContact`. Add `XGrpLinkMem` to `requiresSignature` (safe - p2p-only, relay groups never send/receive it). But `sendXGrpLinkMem` currently uses `sendDirectMemberMessage` -> `sendDirectMessage_` -> `createSndMessage` (`:2197`), which hardcodes `Nothing` signing and sends via `deliverMessage` (no `groupMsgSigning`, no mode partition) - so `requiresSignature` alone would not sign it. Switch `sendXGrpLinkMem` to `sendGroupMemberMessages` (`:2228`), which computes `groupMsgSigning` (`:2231`) and uses the mode at `:2232` (item-7 partition site: binary-signed to a v20+ joiner, unsigned JSON to a pre-20 joiner), and run `ensureUserMemberKey` first so the host has a key to sign with. `xGrpLinkMem` (`:2760`) must `verifyGroupSig` against the key delivered in the message (self-certifying, like `:868`), since `withVerifiedMsg` has no stored host key yet.
|
||||
|
||||
### 6. Receive + confirm the key
|
||||
|
||||
The key is confirmed cryptographically wherever the `XInfo` is signed. Two receive points:
|
||||
|
||||
- Handshake allow-reply - `Subscriber.hs:868` (`XInfo _ _`). The joiner's reply can be signed: `encodeSignedConnInfo` already produces a signed connInfo (used by `encodeXMemberConnInfo`), and the peer version is known by `INFO` (`updatePeerChatVRange`), so sign the allow-reply (item 5) with the joiner's key when the host version supports it. `parseChatMessage` here is `parseChatMessage'` with the signature discarded (`Internal.hs:1793`); switch to `parseChatMessage'`, verify the signature against the key in the `XInfo`, then read and confirm the key. This confirms the joiner's key at join.
|
||||
- Group-message `XInfo` - `xInfoMember` (`Subscriber.hs:2755`), signed via item 7, for ongoing profile/key updates.
|
||||
|
||||
Store/confirm rule at both points, new store fn `setMemberPubKey :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> IO ()` (the key-only, no-role counterpart of the existing `setGroupMemberKeyRole`):
|
||||
- `memberPubKey m = Nothing`, `mKey = Just k` -> store `k`.
|
||||
- `memberPubKey m = Just k0` -> accept only `Nothing` or `Just k0`; a different key is rejected (immutable).
|
||||
|
||||
This is the same pin-or-reject rule as the existing `applyMemberKeyRole` (`Subscriber.hs`, used by the roster): `Nothing` -> pin, `Just k` with `k /= pubKey` -> `Left` reject. Reuse or mirror it.
|
||||
|
||||
Signing is uniform: `XInfo` is `requiresSignature`, so every group `XInfo` (the 836 allow-reply, group profile updates) and `XGrpLinkMem` is signed with the member's key when the recipient version allows (binary). The one unconditionally-unsigned delivery is `XContact` - a JSON connInfo that cannot be signed; the host holds that key as trust-on-first-use, confirmed by the joiner's signed `XInfo` (the 836 reply, then later updates).
|
||||
|
||||
### 7. Signed send - partition recipients by binary capability
|
||||
|
||||
Once `groupKeys = Just`, `groupMsgSigning` (`Internal.hs:2214`) produces a `MsgSigning` for p2p messages, and `createNewSndMessage` (`Store/Messages.hs:236`) stores each `SndMessage` with both `msgBody` (plain encoded message) and `signedMsg_ :: Maybe SignedMsg` (signature over that body). A signed element cannot sit in a JSON batch: `encodeBatchElement (Just sm) body = "/" <> smpEncode (chatBinding, signatures) <> body` (binary), `encodeBatchElement Nothing body = body` (plain JSON), and `encodeBatch` wraps them as `=...` (binary) or `[...]` (JSON) (`Batch.hs:70`, `:130-134`). So the same `SndMessage` yields either form with no re-encoding: keep `signedMsg_` for the signed element, set it to `Nothing` for the unsigned one.
|
||||
|
||||
The send path currently picks one mode for the whole group: `mode = if useRelays' gInfo then BMBinary else BMJson` (`Internal.hs:2232`, `:2549`, `:2676`), so p2p is always `BMJson`. Change, for a p2p group when any message is signed (`any (isJust . signedMsg_) msgs`):
|
||||
|
||||
- Partition the recipients (`toSendSeparate` and `toSendBatched`) by `\`supportsVersion\` relayWebCapVersion` (18, the binary-batch floor).
|
||||
- Binary-capable members -> `batchSndMessagesJSON BMBinary msgs` (signed `/` elements).
|
||||
- Binary-incapable members -> `batchSndMessagesJSON BMJson (map (fmap dropSig) msgs)`, `dropSig m = m {signedMsg_ = Nothing}` (unsigned JSON).
|
||||
- Fold each partition over its own batch (`foldMembers` already runs per list) and concatenate; body references (`VRRef`) are naturally per-partition.
|
||||
|
||||
Relay groups (`BMBinary` for all) and unsigned p2p sends (`BMJson` for all) are unchanged.
|
||||
|
||||
A binary-capable member below `groupMemberKeyVersion` (18-19) receives the signed form, stores it unverified (no key), and can forward it intact via `encodeFwdElement` (`Batch.hs:125`), which preserves `signedMsg_` - which is why the partition is by binary capability, not key possession. A member below 18 receives the unsigned JSON form; in a p2p group `signatureOptional` is true, so it accepts the unsigned message rather than rejecting it.
|
||||
|
||||
Three mode sites to update: `prepareMsgReqs` (`:2549`, main group send), `sendGroupMemberMessages` (`:2232`, member-to-member / introductions), and `:2676`.
|
||||
|
||||
## Revision (2026-07-30): consolidate the send decision, classify delivery
|
||||
|
||||
Items 2, 4, 7 above are the first-cut design - a boolean `user_member_key_sent`, set true on delivery, with the binary/JSON split re-derived in `prepareMsgReqs`. That shipped (commit `261d09ba4` onward) and is the current code. This revision replaces it. Two problems drove it:
|
||||
|
||||
1. **`prepareMsgReqs` re-derives the send mode.** `memberSendAction` (`Internal.hs:2606`) already walks every member - with `useRelays'` and version in hand - to choose `MSASend`; `prepareMsgReqs` then walks the resulting `toSend` again - `partition useBinary` where `useBinary (m,_) = useRelays' gInfo || m supportsVersion relayWebCapVersion` (`:2564`) - recomputing the same decision. One decision, two owners.
|
||||
2. **The boolean models only the happy path.** A `sentTo` `Left` is never marked, so an errored member is re-selected every send forever, uncounted; a disabled member (a `memberSendAction` skip) likewise. No permanent/transient split, no give-up.
|
||||
|
||||
### A. `MemberSendAction` - total, records mode and skip reason
|
||||
|
||||
```haskell
|
||||
data SkipReason = SRUnsendable | SRNotApplicable
|
||||
-- SRUnsendable: disabled / ConnDeleted / failed / GSMemRejected (memberSendAction :2619)
|
||||
-- SRNotApplicable: relay non-target (:2615), self GCUserMember (:2625), forward with no path (:2633)
|
||||
data MemberSendAction = MSASend BatchMode Connection | MSAPending | MSAForwarded | MSASkip SkipReason
|
||||
memberSendAction :: ... -> MemberSendAction -- total, no Maybe
|
||||
```
|
||||
|
||||
- Every current `Nothing` becomes `MSASkip r` with the reason from the branch it came from. For a key recipient (a real, non-self member receiving `XInfo`, not `XGrpMsgForward`), only `:2619` → `SRUnsendable` is reachable.
|
||||
- `MSASend` records the `BatchMode` (`BMBinary` for `useRelays' gInfo || m supportsVersion relayWebCapVersion`, else `BMJson`) - computed where `memberSendAction` already branches on exactly that predicate.
|
||||
- `addMember` (`:2550`) sorts `MSASend BMBinary` / `MSASend BMJson` into pre-partitioned `toSendBin` / `toSendJson`; `prepareMsgReqs` consumes those and drops its own `partition useBinary`. Problem 1 gone.
|
||||
- `GroupSndResult` gains `skipped :: [(GroupMember, SkipReason)]` so the key-marking sees skips. The other consumer, `createMemberSndStatuses` (`Commands.hs:4822`), ignores `skipped` and the mode - unchanged.
|
||||
|
||||
### B. Two columns + `KeySendStatus` sum type
|
||||
|
||||
Replace the boolean with two columns (edit the unreleased `M20260727` migration + both `chat_schema.sql`; no new migration):
|
||||
- `user_member_key_status TEXT` - `NULL` = attempting; `"sent"` = delivered; any other text = terminal error reason.
|
||||
- `user_member_key_attempts INTEGER NOT NULL DEFAULT 0` - retriable-failure count.
|
||||
|
||||
`GroupMember` field `userMemberKeyStatus :: KeySendStatus` (was `userMemberKeySent :: Bool`), a sum type constructed from the two columns:
|
||||
|
||||
```haskell
|
||||
data KeySendStatus = KSSent | KSError Text | KSAttempts Int
|
||||
-- from (status :: Maybe Text, attempts :: Int):
|
||||
-- (Just "sent", _) -> KSSent
|
||||
-- (Just reason, _) -> KSError reason -- any non-null, non-"sent" text ("sent" reserved)
|
||||
-- (Nothing, n) -> KSAttempts n -- still attempting, n prior retriable failures
|
||||
```
|
||||
|
||||
Two columns, not one text field, so each marking outcome is one uniform bulk write (C): success touches only `status`, a retry touches only `attempts` (`attempts = attempts + 1`, no per-row value), an error groups by reason. Member creation default `KSAttempts 0` = (`NULL`, `0`).
|
||||
|
||||
Selection: `memberNeedsKey m = m supportsVersion groupMemberKeyVersion && case userMemberKeyStatus m of { KSAttempts n -> n < maxKeySendAttempts; _ -> False }`. Comparing the count to config (E) means no separate "abandoned" state; raising the cap re-includes maxed-out members.
|
||||
|
||||
### C. Marking - classify once, from `GroupSndResult`
|
||||
|
||||
Per key-recipient, one outcome, written as partitioned bulk updates:
|
||||
- **Delivered** (`sentTo` enqueue `Right`, or `pending`, or `forwarded`) → `status = "sent"`.
|
||||
- **Skipped** (`skipped`): `SRUnsendable` → `status = <reason>` terminal (a disabled connection is terminal in practice - `APIEnableGroupMember` is effectively never called - so stop re-selecting it); `SRNotApplicable` → untouched.
|
||||
- **Errored** (`sentTo` `Left`): `terminalKeySend e` → `status = <reason>` (grouped by reason); else → `attempts = attempts + 1`.
|
||||
|
||||
The send is async: this classifies only the synchronous **enqueue** result. `submitPendingMsg` (`Agent.hs:2068`) hands the message to the SND worker, which does the network send and emits `SENT` / `MERR` (`Agent.hs:2196,2274`) - so AUTH / QUOTA / NETWORK / BROKER never reach this point; the agent retries them itself. `temporaryOrHostError` is therefore the wrong classifier here - it triages the async errors that cannot occur, and misjudges the few that can.
|
||||
|
||||
### D. `terminalKeySend` - closed terminal set, default retriable
|
||||
|
||||
```haskell
|
||||
terminalKeySend :: ChatError -> Bool
|
||||
terminalKeySend = \case
|
||||
ChatErrorAgent {agentError} -> case agentError of
|
||||
CONN SIMPLEX _ -> True -- connection has no send queue (prepareConn :1821)
|
||||
CONN NOT_FOUND _ -> True -- connection / ratchet gone (getConn :1812)
|
||||
NO_USER -> True -- user deleted
|
||||
_ -> False
|
||||
_ -> False
|
||||
```
|
||||
|
||||
Everything else is retriable, bounded by the cap: `CMD PROHIBITED` (ratchet resync, `Agent.hs:1826`), `CRITICAL True` (agent DB lock, `SEDatabaseBusy`), `INACTIVE` (agent suspended), `ChatErrorStore` (chat DB contention), `INTERNAL` (catch-all - ambiguous, so retriable), and oversize (`CMD LARGE` / batch `CEInternalError "large message"` / `CEException "large compressed message"` - rare, a global profile-size problem, self-limiting under the cap; a dedicated `ChatErrorType` constructor for the batch case is a separate cleanup, out of this branch). Inverting to a terminal whitelist is deliberate: at the enqueue phase, mislabeling a transient error permanent abandons a member whose next send would succeed, while mislabeling a permanent error retriable costs only a few capped sends.
|
||||
|
||||
Exhaustive reachability (why the terminal set is these three): the only synchronous producers are `getConn_` (`Agent.hs:1806`), `prepareConn` (`:1814`), and `enqueueMessageB`/`storeSentMsg` (`:2062`). All network/server errors (`SMP`/`BROKER`/`PROXY`/`NTF`/`XFTP`, and `AUTH`/`QUOTA`) are async (MERR) and unreachable here; `AGENT (A_*)` are receive/queue-op side; `CONN DUPLICATE`/`NOT_ACCEPTED`/`NOT_AVAILABLE`, `CMD SYNTAX`/`NO_CONN`/`SIZE`, `NTF`/`XFTP`/`FILE`/`RCP`/`NOTICE`/`CRITICAL False` are other paths.
|
||||
|
||||
### E. Config
|
||||
|
||||
`maxKeySendAttempts :: Int` in the chat config (value immaterial - a small cap like 5; over-retrying a rare ambiguous error is cheap).
|
||||
|
||||
## Implementation status (2026-07-30)
|
||||
|
||||
- Items 1, 3, 5, 6 - implemented as described: versions, key generation, distribution (`XContact` / `XGrpLinkMem` / `XInfo`), receive pin-or-reject.
|
||||
- Items 2, 4, 7 - the boolean baseline is **replaced by the Revision (A-E)**, which is now implemented and compiles (`cabal build lib:simplex-chat`, both backends' schema + migration updated):
|
||||
- A - `MemberSendAction` total, records `BatchMode` and `SkipReason`; `sendBatchMode` is the single owner of the binary/JSON decision; `sendGroupSignedMessages_` dedups then classifies with list comprehensions into pre-partitioned `toSendBin`/`toSendJson`; `prepareMsgReqs` reads them (no re-derivation); `GroupSndResult` gains `skipped`.
|
||||
- B - two columns `user_member_key_status TEXT` / `user_member_key_attempts` (migration `M20260727` + both `chat_schema.sql`), field `userMemberKeyStatus :: KeySendStatus` built by `toKeySendStatus`, store fns `setMembersKeyStatus` / `incMembersKeyAttempts`.
|
||||
- C - `markKeySends` classifies each key-recipient once from `GroupSndResult` and writes partitioned bulk updates; `memberNeedsKey` selects `KSAttempts n < maxKeySendAttempts`.
|
||||
- D - `terminalKeySend` = {`CONN SIMPLEX`, `CONN NOT_FOUND`, `NO_USER`}; everything else retriable.
|
||||
- E - `maxKeySendAttempts = 5`.
|
||||
|
||||
Remaining work:
|
||||
- Regenerate the client-type mirrors: `userMemberKeyStatus` still shows as `userMemberKeySent: boolean` in the generated `types.ts`, `_types.py`, and `bots/api/TYPES.md` (generated by `bots/src/API/Docs/Generate*.hs`).
|
||||
- Tests: the branch adds none beyond `ProtocolTests` field plumbing - no coverage of distribution, pin-or-reject, signed send/verify, or the classification.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- Names, to confirm or adjust: columns `user_member_key_status` / `user_member_key_attempts`, field `userMemberKeyStatus :: KeySendStatus`, constructors `KeySendStatus`/`KS*` and `SkipReason`/`SR*`, config `maxKeySendAttempts`.
|
||||
- Whether a `SRUnsendable` skip is recorded as a terminal `error` (proposed: yes - a disabled connection is terminal in practice).
|
||||
|
||||
Resolved: both key writes required (`groups.member_priv_key` and own-row `member_pub_key`). `XContact` includes the unsigned key at member creation and the signed allow-reply confirms it. Reuse `relayWebCapVersion` (18) as the binary floor. Key change on receipt - reject any change, immutable. Signed send (item 7) - partition by binary capability, and (Revision A) the mode is decided once in `memberSendAction`, not re-derived. Key distribution runs in all p2p groups including incognito. First send after generation is signed. Sign criteria unchanged. Handshake allow-reply signed, confirms the key at join. Status is a two-column `KeySendStatus` sum type (not a boolean); delivery is classified terminal-vs-retriable with a capped attempt counter, terminal set closed (D).
|
||||
@@ -0,0 +1,264 @@
|
||||
# Badge proofs bound to the conversation, and file size limits decided in core
|
||||
|
||||
**Date:** 2026-09-04
|
||||
**Branch:** ep/p2p-group-signing. The work depends on member keys and signed profile messages, which this branch adds.
|
||||
|
||||
## Summary
|
||||
|
||||
A badge is a credential issued to a user who supports SimpleX Chat. The user shows it to others by putting a proof in their profile. This plan binds every proof to the place where it is shown: a proof in a group profile to the sender's identity in that group, a proof attached to a file to the conversation and to that file. Verification checks the binding.
|
||||
|
||||
The same badge raises the size limit for files the user sends. Today each app decides whether a received file is within the limit, using the sender's profile as it is at the moment of display. After this plan, the core library decides once, when the file invitation arrives, from a proof in the invitation; stores the decision with the file; and the apps read it. A second proof arrives with the file description, the record that says where the file's chunks are stored, and is checked before the download starts.
|
||||
|
||||
The changes:
|
||||
|
||||
1. Three new presentation headers: one for a profile shown in a chat, one for a file invitation, one for a file description. The random header stays valid where there is no chat yet, and for the profile in direct chats until the direct binding reaches the handshake.
|
||||
2. In p2p groups a badge is accepted only from a message signed by the member. The member connection handshake is signed in both directions, and the profile sent in it is stored, so the badge appears when two members connect.
|
||||
3. In channels a badge is accepted from any profile message, because member keys there come from the roster, the member list signed by the channel owner.
|
||||
4. Files above the default limit include a proof in the invitation and a proof in the description, in every chat type. The core library verifies both. The decision is stored on the file and shown by the apps from one field.
|
||||
5. Forwarding a file above the forwarder's limit is refused with an alert before the forwarding sheet opens, and again, for the chosen destination, before anything is uploaded.
|
||||
6. A received file keeps its two proofs, so a file re-sent to a new member as part of history keeps them; the sender's own files get fresh proofs from the credential.
|
||||
|
||||
Two new columns on `files`, and a new table `file_badge_proofs` holding the invitation proof and the description proof of a file, kept for history. One new function in simplexmq, the hash of the fields shared by all descriptions of one upload.
|
||||
|
||||
## Terms
|
||||
|
||||
**Core library.** The Haskell library shared by all apps. The apps display what it decides.
|
||||
|
||||
**simplexmq.** The library below the core library that transfers messages and files. The core library calls it and never changes its formats without a change there.
|
||||
|
||||
**P2p group.** A group whose members connect to each other directly. A new member is introduced to each existing member by the admin who admitted them, and until the two connect, the new member's messages reach the existing member forwarded by an admin.
|
||||
|
||||
**Channel.** A group whose messages go through relay servers. Members do not connect to each other. The channel owner signs the member list, the roster, which establishes each member's key.
|
||||
|
||||
**Introduction.** The messages by which an admin tells one member about another: `XGrpMemNew` to the existing members, `XGrpMemIntro` and `XGrpMemFwd` to the two members being connected. They include the member's profile and public key.
|
||||
|
||||
**Handshake.** The exchange when two members connect directly. Each side sends `XGrpMemInfo` with its group profile.
|
||||
|
||||
**File description.** The record the sender sends after an upload completes. It lists where each chunk of the file is stored and the keys to download and decrypt them. A file cannot be downloaded without it. It is sent in parts, in `XMsgFileDescr` messages.
|
||||
|
||||
**History.** The recent items the host sends to a member who has just joined. A file item is re-sent as a new invitation together with its description.
|
||||
|
||||
**Badge credential.** The secret record issued to the user: an issuer key index, a master key, a BBS signature, and the badge information (type, expiry, extra). Stored in the user's own profile row. Type `BadgeCredential` in `Badges.hs`.
|
||||
|
||||
**Badge proof.** A BBS proof generated from the credential for one presentation. It discloses the badge information and hides the master key. Different proofs from one credential cannot be linked. Type `BadgeProof`. In a profile it is sent as `Profile.badge`.
|
||||
|
||||
**Badge status.** What a receiver concludes about a proof: `BadgeStatus` (`Badges.hs:114`) — `BSActive`, `BSExpired`, `BSExpiredOld`, `BSFailed`, `BSUnknownKey` — computed by `mkBadgeStatus`, which treats a badge as active for seven days past its expiry.
|
||||
|
||||
**Presentation header.** A byte string that is an input to proof generation and to proof verification. A proof verifies only with the header it was generated with. Type `ProofPresHeader` in `Badges.hs`. `PHTest` is a random nonce.
|
||||
|
||||
**Chat binding.** The byte string that identifies the sender in one conversation, produced by `encodeChatBinding` (`Protocol.hs:444`). Message signatures and shared contact cards are computed over it. For a direct chat it is `encodeChatBinding CBDirect adHash`, where `adHash` is the hash of the connection's ratchet data, which both sides obtain with `getConnectionRatchetAdHash`. For a p2p group it is `encodeChatBinding CBGroup (smpEncode (memberId, memberKey))`. For a channel it is `encodeChatBinding CBGroup (smpEncode (publicGroupId, memberId))`. `groupBindingData` (`Internal.hs:2261`) computes the inner part for groups.
|
||||
|
||||
**Member key.** The Ed25519 key a member holds for one group. It is created when first needed — at group creation on this branch, or by `createUserMemberKey` before the first signed message — and the public key is sent in introductions and in `XInfo`.
|
||||
|
||||
**Default limit.** `maxFileSize`, 1GB. A supporter badge raises it to 2GB, a legend badge to 5GB (`maxXFTPFileSize`, `Badges.hs:201`). The three sizes are `FileSizeLimits` in `ChatConfig`, `defaultFileSizeLimits` in production and lowered in tests. The default limit is also the size above which a proof is required.
|
||||
|
||||
## 1. Presentation headers
|
||||
|
||||
File: `src/Simplex/Chat/Badges.hs`.
|
||||
|
||||
Extend `ProofPresHeader`:
|
||||
|
||||
```haskell
|
||||
data ProofPresHeader
|
||||
= PHTest ByteString
|
||||
| PHChat ByteString
|
||||
| PHFileInv ByteString String Integer
|
||||
| PHFileDescr ByteString String Integer ByteString (Maybe UTCTime)
|
||||
| PHUnknown Char ByteString
|
||||
```
|
||||
|
||||
- `PHChat` holds the chat binding.
|
||||
- `PHFileInv` holds the chat binding and the file size from the invitation.
|
||||
- `PHFileDescr` holds the same two values, then the shared description hash (section 8) and the file expiration.
|
||||
|
||||
The file name is not part of either header: `validateFileInvitation` replaces it with a name valid on the local file system, and history re-sends the stored name.
|
||||
|
||||
One constructor serves every chat type, because the chat binding already encodes the type of chat in its first byte. Each constructor gets a tag character in `ProofPresHeaderTag` and an encoding in the `StrEncoding` instance, in the same style as `PHTest`. The file expiration is optional, because a server may grant none; it is encoded as `strEncode` of the time, or one fixed byte when absent. The badge's own expiry is a time and is encoded with `strEncode` in the disclosed messages (`badgeInfoMessages`, `Badges.hs:296`).
|
||||
|
||||
`verifyBadgeWith` today verifies a proof with whatever header the proof contains. After this change the receiver first checks that the header names the sender as the receiver knows them, and only then runs BBS verification with that header. `proofPresHeaderAccepted` is removed. What the receiver knows is already held by existing types, so no new type is added:
|
||||
|
||||
- A contact request, link data, and the profile in a direct chat: the header must be `PHTest`.
|
||||
- A file in a direct chat: the receiver has the contact's connection and obtains its ratchet hash from the agent, as `newContentMessage` does for a contact card (`Subscriber.hs:1883`). The binding in the header must equal `encodeChatBinding CBDirect adHash`.
|
||||
- A profile or a file in a group: the receiver has the `GroupInfo` and the sender's `GroupMember`. The binding in the header must equal `groupBindingData` for that member — for a channel the group's public id and the member id; for a p2p group the member id and a key that passes the key check.
|
||||
|
||||
**The key check.** A p2p binding contains the sender's member key. The receiver may know that member's key from the introduction or from a signed message, or may not know it yet. If the receiver knows a key and it differs from the key in the header, the proof fails. Otherwise the key in the header is used for this verification and never stored; keys are stored only by the introduction and by `storeMemberKey`.
|
||||
|
||||
The file headers are checked the same way and then further: `PHFileInv` must also name the file size as received; `PHFileDescr` must also hold the hash of the received description and the expiration received with it.
|
||||
|
||||
`PHUnknown` fails every check. A proof from a released client, which presents `PHTest` in groups, fails in groups; no badge has been issued yet, so nothing in use is affected. A released client that receives one of the new headers verifies it, because its `proofPresHeaderAccepted` admits unknown tags and BBS verification runs with the header bytes as sent. No protocol version change is needed.
|
||||
|
||||
`groupBindingData` moves from `Internal.hs` to `Protocol.hs`, beside `encodeChatBinding`, because the store modules import `Protocol` and not `Internal`. Module order fixes where the check is computed: `Badges.hs` is imported by `Types.hs`, which `Protocol.hs` imports, so the header check in `Badges.hs` takes plain values — the expected binding for a channel or a direct chat, or the member id and the stored key for a p2p group — and the callers in the store compute them from `GroupInfo` and `GroupMember` with `groupBindingData`. `profileBadgeVerified` is in `Types.hs` today and cannot call `groupBindingData`; it moves to `Store/Shared.hs`, beside the other badge-verifying store code.
|
||||
|
||||
`SimplexDomainProof` (`Names.hs:37`) also uses `ProofPresHeader`, as an opaque value. Its verification is unchanged.
|
||||
|
||||
## 2. Presenting the profile badge
|
||||
|
||||
File: `src/Simplex/Chat/Library/Internal.hs`, `presentUserBadge` (`:2178`).
|
||||
|
||||
The function generates the proof for an outgoing profile. It takes a new argument, `Maybe GroupInfo`. With `Nothing` it generates `PHTest` as today. With `Just gInfo` it generates `PHChat` from the group's chat binding and the user's own member key in that group, calling `createUserMemberKey` first when the group has no key yet.
|
||||
|
||||
Call sites that send a profile into a group pass the group: `Commands.hs:3953` (join via group link, the group case), `:4291` (the owner's profile to a relay); `Subscriber.hs:480` (the group case), `:611`, `:799`, `:813`, `:941`, `:1220`, `:3271`; `Internal.hs:2539` (`sendGroupProfileUpdate`). All other call sites send a direct profile and pass `Nothing`.
|
||||
|
||||
The profile in a direct chat keeps `PHTest`; moving it to `PHChat` is a later change.
|
||||
|
||||
## 3. Accepting the profile badge
|
||||
|
||||
A received badge is verified today at seven places in the store layer, each verifying the proof with no knowledge of the sender: `profileBadgeVerified` (`Types.hs:834`), `createContact_` (`Store/Shared.hs:420`), `createJoiningMember` (`Store/Groups.hs:2089`), `createNewMemberProfile_` (`Store/Groups.hs:2459`), two contact request sites (`Store/ContactRequest.hs:169, 236`), and `linkDataBadge` (`Internal.hs:2194`).
|
||||
|
||||
The direct sites keep verifying with `PHTest`. The group sites gain the `GroupInfo` and the sender's `GroupMember` where they do not have them already: `updateMemberProfile` and `updateContactMemberProfile` (`Store/Groups.hs:3430, 3453`) have the member and gain the group, and pass both to `profileBadgeVerified`; `createNewMemberProfile_` gains both from `createNewGroupMember`. A badge from a message that was not verified with the member's key is not verified at all: the caller removes it from the profile before storing, so the store function sees no badge.
|
||||
|
||||
Where a badge is accepted in a p2p group:
|
||||
|
||||
- `xInfoMember` (`Subscriber.hs:2738`): only when the `XInfo` was verified with the member's key. `RcvMessage.msgSigned` is `MSSVerified` when a stored key verified it. When the same message delivers the key, `storeMemberKey` has verified the signature with that key, and the badge is kept on the same basis. Otherwise the badge is removed from the profile before `processMemberProfileUpdate`.
|
||||
- `xGrpLinkMem` (`:2744`): the host's profile to the joiner, signed on this branch.
|
||||
- The member connection handshake, section 4.
|
||||
|
||||
Where a badge is dropped in a p2p group: `createJoiningMember` and `createNewMemberProfile_`. The profile is stored without the badge, and the member's badge arrives at the handshake.
|
||||
|
||||
In a channel a member's profile arrives in three ways: in `XMember` when the member joins, which the member signs and the owner verifies with the roster key (`verifyKey`, `Subscriber.hs:1656`) before `createJoiningMember`; in the introduction from a relay, stored by `createNewMemberProfile_`; and in `XInfo`. A badge in any of them is kept and verified, because member keys in a channel are established by the roster, which the owner signs, and `xGrpMemNew` rejects a relay that asserts a different key (`Subscriber.hs:3127-3134`).
|
||||
|
||||
## 4. The member connection handshake
|
||||
|
||||
When two p2p members connect, each sends `XGrpMemInfo` with its group profile. It is sent from two places: the reply on the member connection (`Subscriber.hs:816`) and the join of the member connection and of the direct connection to the same member (`:3272`, both joined with the same message at `:3282-3283`). The four receiving sites — `:590, 620` on the direct connection, `:810, 823` on the member connection — each have a "TODO update member profile" comment.
|
||||
|
||||
- **Sign the join side.** `xGrpMemFwd` sends `encodeConnInfo $ XGrpMemInfo ...` (`:3272`), plain JSON. Change it to `encodeSignedConnInfo` with `groupMsgSigning` when the agreed version is at least `relayWebCapVersion`. The agreed version is computed three lines below, as `chatV`; move that computation above the send. Call `createUserMemberKey` before signing, here and at the reply site, as every other signing site does.
|
||||
- **Parse the signature on CONF.** The member CONF site parses with `parseChatMessage` (`:745`), which discards the signature. Change it to `parseChatMessage'`, as INFO already does (`:823`).
|
||||
- **Verify the signature.** At `:810` and `:823` verify the signed `XGrpMemInfo` with the member's stored key. `XGrpMemInfo` names no key, and the handshake follows the introduction, which stored the key. A member with no stored key is not verified.
|
||||
- **Store the profile.** At `:810` and `:823` call `processMemberProfileUpdate` with the profile, with the badge removed when the signature did not verify.
|
||||
- `:590` and `:620` stay as they are. The profile there is the same group profile, received over the direct connection to the member. The contact for a member shares the member's profile row (`createIntroToMemberContact`, `Store/Groups.hs:2684-2685`), so storing it once, on the member connection, updates both.
|
||||
|
||||
## 5. The file size limit at send
|
||||
|
||||
`checkSndFile` (`Commands.hs:3973`) compares the file size with the sender's limit and is called from the two content send paths only, with `Nothing` for an incognito send (`:4773`, `:4858`). `APIUploadStandaloneFile` (`:3628`) checks the hard limit and never the badge.
|
||||
|
||||
The comparison stays where it is, with one change: the limit at send counts a badge as active until one day after its expiry, instead of the seven days `maxXFTPFileSize` allows a receiver. `maxSndXFTPFileSize` in `Badges.hs` computes the send limit with that rule, from `FileSizeLimits` and the current time, and the apps use the same rule for the limit they show on the compose screen (section 11), so the compose screen never offers a size the send refuses.
|
||||
|
||||
Standalone uploads do not apply badge limits. `APIUploadStandaloneFile` keeps the hard limit.
|
||||
|
||||
## 6. The file invitation proof
|
||||
|
||||
**Type.** `FileInvitation` (`Types.hs:1555`) gains `fileBadge :: Maybe BadgeProof`. The JSON instance omits absent fields, so a released client ignores it.
|
||||
|
||||
**Generation.** In `xftpSndFileTransfer_` (`Internal.hs:438`), when the file is above the default limit and the send is not incognito, generate a proof with `PHFileInv` from the chat binding and the file size, and set it in the invitation. `sndFileChatBinding` computes the binding from `ContactOrGroup`: `CBDirect` with the ratchet hash of the contact's connection, obtained from the agent as `shareChatBinding` does (`Commands.hs:4685`); `CBChannel` with the public group id for content sent as the group; the member binding otherwise. `CGGroup` gains `ShowGroupAsSender`, which both send paths already hold.
|
||||
|
||||
A file sent as the group is bound to the channel because a relay forwards it as `FwdChannel` with no author, so the receiver has no member to rebind to. The receiver picks the same case from the item's `showGroupAsSender`.
|
||||
|
||||
**Verification.** A file invitation arrives at three places: `processFileInvitation` (`Subscriber.hs:1957`), for a file in a content message in a direct chat or a group, called with a closure that creates the transfer; `processGroupFileInvitation'` (`:2437`), for the older `XFile` event in a group; and `processFileInvitation'` (`:2422`) for `XFile` in a direct chat. The proof is checked against the file size and against the sender: the connection's ratchet hash for a contact, the group and member for a member. The result is the decision below, passed to `createRcvFileTransfer` or `createRcvGroupFileTransfer`, which gain it as an argument and write it.
|
||||
|
||||
**The decision.** A file is either allowed or prohibited; when prohibited, the apps need the limit that applied and why.
|
||||
|
||||
```haskell
|
||||
data FileProhibited = FileProhibited {maxSize :: Int64, badgeStatus :: Maybe BadgeStatus}
|
||||
```
|
||||
|
||||
`Nothing` when allowed; `Just` when prohibited:
|
||||
|
||||
- Above the default limit, and the invitation has no proof: the default limit and `Nothing`.
|
||||
- Above the default limit, and the proof fails the header check or BBS verification: the default limit and `BSFailed`; `BSUnknownKey` when the issuer key index is not configured.
|
||||
- Above the default limit, and the proof verifies but the badge has expired beyond the grace: the default limit and the expiry status.
|
||||
- Above the limit that a verified, active badge allows: that limit and `BSActive`.
|
||||
|
||||
The sender's profile badge plays no part. An invitation without a proof gets the default limit in every chat type.
|
||||
|
||||
**Storage.** Two new columns on `files`: `file_max_size INTEGER`, the limit that applied, and `file_badge_status TEXT`, the badge status, both NULL when the file is allowed. A file is prohibited when `file_max_size` is set; `file_badge_status` is NULL when the invitation had no proof. `BadgeStatus` gains `TextEncoding` and field instances for the column, as `MsgSigStatus` has (`Types/Shared.hs:137`). `createRcvFileTransfer` and `createRcvGroupFileTransfer` (`Store/Files.hs:448, 469`) write both. Sent and local files, and rows from before this change, hold NULL.
|
||||
|
||||
A proof that verifies is stored in `file_badge_proofs` (section 12) with kind `inv`, for history (section 9); a proof that failed is not stored, as `files.file_badge_status` records that it failed. A sent file stores its own proof the same way.
|
||||
|
||||
**The chat item.** `CIFile` (`Messages.hs:684`) gains `fileProhibited :: Maybe FileProhibited`. `MaybeCIFIleRow` (`Store/Messages.hs:2279`) gains the column, the three queries that select the file columns gain `f.file_max_size, f.file_badge_status`, and the two `maybeCIFile` constructors and the five other `CIFile` constructions (`Internal.hs:449`, `Subscriber.hs:2001, 2456, 2472`, `Commands.hs:5009`) set it.
|
||||
|
||||
**Accepting a file.** `acceptFileReceive` (`Internal.hs:746`) fails with `CEFileSize` when the file is prohibited. The apps stop a tap before that, from the same field.
|
||||
|
||||
## 7. The file description proof
|
||||
|
||||
**Type.** `XMsgFileDescr` (`Protocol.hs:454`) gains `fileBadge :: Maybe BadgeProof`, encoded with `.=?` like `fileExpires`.
|
||||
|
||||
**Generation.** The description proof is generated when the upload completes, which may be hours after the send, from the credential as it is then. If the badge expired meanwhile, the proof still verifies at receivers, which allow seven days past expiry; if the user hid the badge, the credential row is still there; if the badge was renewed, the new credential is used. Only a credential deleted outright leaves the file without a description proof, and receivers then prohibit it. Nothing is copied at send.
|
||||
|
||||
In the `SFDONE` handler (`Subscriber.hs:209`), when the file is above the default limit, the user holds a credential, and the send was not incognito — the handler has the chat item and its contact or group, so the same condition as at the invitation — generate one proof with `PHFileDescr`: the values of the invitation header, `sharedDescriptionHash` of any one recipient description, and `fileExpires`. `sendFileDescriptions` (`:283`) sets it on the last part for each recipient, in the direct branch (`:237`) and the group branch (`:252`) alike; the parts are split in `splitText` (`:297`).
|
||||
|
||||
**Verification.** When the file is allowed and above the default limit:
|
||||
|
||||
1. The part that completes the description must have a proof. `processFDMessage` (`Subscriber.hs:1940`) receives every part and calls `receiveViaCompleteFD` when the description is complete and the file was accepted. It verifies the proof on the completing part, before that call, and on success stores it in `file_badge_proofs` with kind `descr`. On failure it records the same decision as a failed invitation proof — the default limit and the badge status on `files` — so the file is refused by `acceptFileReceive` and the apps show the reason from one field. A file that requires a badge is received from the description message: `validateFileInvitation` stores its description as incomplete, so a complete description in an invitation cannot start a download unverified.
|
||||
2. Check the header: binding, name, size as at the invitation; the hash equal to `sharedDescriptionHash` of the parsed description; the expiration equal to `fileExpires` from the message.
|
||||
3. If the expiration is present and in the past, fail.
|
||||
4. If the expiration is absent, accept. Older servers grant no expiration. This is tightened once servers are upgraded.
|
||||
|
||||
The error set on failure is a new `FileError` value, so the apps can name the reason.
|
||||
|
||||
## 8. The shared description hash
|
||||
|
||||
File: `Simplex.FileTransfer.Description` in simplexmq.
|
||||
|
||||
`sndFileToDescrs`, in the agent's `Simplex.FileTransfer.Agent`, builds one description per recipient from one set of values. The values common to the sender's description and every recipient's are `size`, `digest`, `key`, `nonce`, `chunkSize`, and for each chunk `chunkNo`, `chunkSize` and `digest`. `party` differs between sender and recipient, `replicas` differ per recipient, and `redirect` is absent from the sender's.
|
||||
|
||||
Add `sharedDescriptionHash :: FileDescription p -> ByteString`: SHA-512 over a fixed encoding of those values in that order. Defining it beside the type keeps it in step with the format.
|
||||
|
||||
Because the hash ignores replicas, one proof is valid for every recipient's description, including one re-sent later as history. In a channel the sender sends descriptions to the relays (`getGroupRelayMembers`, `Subscriber.hs:260`), which forward them to the members; whichever description a member receives, the hash is the same.
|
||||
|
||||
## 9. History
|
||||
|
||||
`sendHistory` (`Internal.hs:1366-1481`) re-sends a file item to a new member as a new invitation built from the stored name and size (`invCompleteDescr`, `:1445`) with the description in `XMsgFileDescr` parts (`:1481`). Content is not signed, so nothing from the original messages survives.
|
||||
|
||||
Both proofs are read from `file_badge_proofs` by kind. For a file received from another member they are re-sent unchanged: they are bound to the original sender, and history names that sender (`fwdSender`, `:1466`), so the new member verifies them against that member's binding. For the host's own files the stored proofs are re-sent too, and are re-made from the credential over the stored headers only when the badge that made them is past the send grace and the current badge is active — never for a signed item, whose original bytes are forwarded.
|
||||
|
||||
`fileExpired` (`:1439-1443`) decides which files history re-sends by the item's age against `rcvFilesTTL`, two days, and ignores the granted expiration stored with the file. That check should use the stored `fileExpires`; it is noted here because it bounds when the stored proofs are read.
|
||||
|
||||
## 10. Forwarding
|
||||
|
||||
Forwarding a file uploads it again from the local copy, so the forwarder's own limit applies. The forward plan (`APIPlanForwardChatItems`, `Commands.hs:1004-1050`) runs before the destination is chosen; it checks whether the file was received and exists, and nothing about size. A too-large forward fails at send, after the user has chosen the recipient.
|
||||
|
||||
Two checks, both before any upload:
|
||||
|
||||
- **In the apps, before the sheet.** The app has the file size and the user's own badge, so it decides without calling core, with the same send rule the compose screen uses (section 11). The per-item Forward action, which opens the sheet directly today — `forwardedChatItems = [chatItem]` (`ChatView.swift:2407`), `SharedContent.Forward(listOf(cItem), cInfo)` (`ChatView.kt:625`) — shows the alert instead when the file is above that limit. Multi-select (`ChatView.swift:1515`, `ChatView.kt:347`) makes the same check on the selected items before calling the plan, and shows the same alert with the count when any file is above it. The plan command is unchanged.
|
||||
- **In the sheet.** When a forwarded item has a file above the default limit, the sheet where the destination is chosen disables every chat in which the user is incognito — a contact with `contactConnIncognito`, a group with `memberIncognito` on the membership — because the badge is not presented there and the file cannot be sent.
|
||||
- **In the forward command**, `APIForwardChatItems` (`Commands.hs:1052`), once the destination is known: each file is checked against the destination's limit — the default for an incognito membership, as `checkSndFile` decides today — before any item is created or upload started. A file above it fails the command with a new `ChatErrorType` value naming the count, which the apps show as the same alert.
|
||||
|
||||
## 11. The apps
|
||||
|
||||
The receive decision is computed in eleven places from the sender's profile: `getMaxFileSize(protocol, senderProfile)` (`FileUtils.swift:280`, `Utils.kt:477`) and `fileSizeValid(file, senderProfile)` (`CIFileView.swift:236`, `CIFileView.kt:241`), used in `CIFileView`, `CIImageView`, `CIVideoView`, `ChatView.swift:2323`, `ChatItemView.kt:452`, with a second copy of the check in each video view (`receiveFileIfValidSize`).
|
||||
|
||||
- `CIFile` gains `fileProhibited` in `ChatTypes.swift:4681` and `ChatModel.kt:4249`, with `FileProhibited` decoded from core.
|
||||
- `fileSizeValid` becomes a check that `fileProhibited` is absent and takes no profile. The alert is worded by `badgeStatus` — no badge, unverified, unknown key, expired, or above the badge's limit — with `maxSize` as the figure.
|
||||
- `getMaxFileSize` loses the profile argument for received files. The compose screen keeps computing the sender's own limit from the user's own badge (`ComposeView.swift:1272`, `ComposeView.kt:1423`), with the one-day rule of section 5 instead of the seven-day status; `ShareModel.swift:448, 539` and `ComposeView.kt:118` start passing the profile, so they stop showing 1GB to a badge holder.
|
||||
- `ciSenderProfile` and the `senderProfile` parameters are removed from the file, image and video views and their call sites in `FramedItemView` and `ChatPreviewView`.
|
||||
- `FileError` gains the new value in `ChatTypes.swift` and `ChatModel.kt`, with a message for it.
|
||||
- The generated API mirrors — `bots/api/TYPES.md`, `types.ts`, `_types.py` — are regenerated for `CIFile`, `FileProhibited` and `FileInvitation`.
|
||||
|
||||
## 12. Schema and fixtures
|
||||
|
||||
Migration `M20260904_file_badges`, SQLite and Postgres:
|
||||
|
||||
```sql
|
||||
ALTER TABLE files ADD COLUMN file_max_size INTEGER;
|
||||
ALTER TABLE files ADD COLUMN file_badge_status TEXT;
|
||||
|
||||
CREATE TABLE file_badge_proofs(
|
||||
badge_proof_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
proof_kind TEXT NOT NULL,
|
||||
badge_proof BLOB NOT NULL,
|
||||
badge_pres_header BLOB NOT NULL,
|
||||
badge_key_idx INTEGER NOT NULL,
|
||||
badge_type TEXT NOT NULL,
|
||||
badge_expiry TEXT NOT NULL,
|
||||
badge_extra TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(file_id, proof_kind);
|
||||
```
|
||||
|
||||
The six proof columns are the fields of `BadgeProof` — the proof, the presentation header, the issuer key index, and the disclosed type, expiry and extra — with a conversion of its own. A file is one direction, so it has at most two proofs; `proof_kind` is `inv` or `descr`, and the unique index makes each a single upsert. The row references the file with `ON DELETE CASCADE`, so it is removed with the file, which is how file rows are removed today — by cascade from chat items, contacts and groups rather than by one function. Postgres uses `BYTEA`, `BIGINT` and `GENERATED ALWAYS AS IDENTITY`. Register in both `Migrations.hs` lists and in `simplex-chat.cabal`. Update both `chat_schema.sql` files and `chat_query_plans.txt`; `SchemaDump.hs` compares them.
|
||||
|
||||
## 13. Tests
|
||||
|
||||
- `BadgeTests.hs`: each header encodes and decodes; a proof generated with one header fails with another; the key check accepts an unknown key, accepts an equal key, rejects a different one.
|
||||
- `ChatTests/Profiles.hs`, beside the seven badge tests: a badge in a p2p group appears at the other member after the connection handshake and not before; a proof presented under another member's binding is rejected; a badge in a channel appears on presentation. Existing tests that assert a badge at introduction time are updated.
|
||||
- `ChatTests/Files.hs`, beside `testXFTPGroupFileTransfer`: a file above the default limit from a badge holder is received in a group and in a direct chat; an invitation whose proof was made for another member is refused; a description with a changed hash fails before download; a file above the limit received as history is received by the new member; a forward into an incognito membership above the default limit fails the command before any upload.
|
||||
- `ProtocolTests.hs`: the new fields in `FileInvitation` and `XMsgFileDescr`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Moving the direct chat profile proof to `PHChat`.
|
||||
- Requiring an expiration in the description proof, once servers grant one.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."2d4b40e10475fd2d09c76c590df89f384ac45c85" = "04jr59xqs5lc193w761di3bpnxwz6638yg4yvcmgq5m4z5djj8jk";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."a00e225d74dfa03aba2293530416f69c55373bed" = "0d0bd5w7rf862fswk6p2bcafjwxpvg78apz1yjcva8i2la8as1k5";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
|
||||
@@ -164,6 +164,7 @@ library
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges
|
||||
else
|
||||
exposed-modules:
|
||||
@@ -338,6 +339,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges
|
||||
other-modules:
|
||||
Paths_simplex_chat
|
||||
|
||||
+5
-3
@@ -28,7 +28,7 @@ import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (fromMaybe, mapMaybe)
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (getCurrentTime, nominalDay)
|
||||
import Simplex.Chat.Badges (badgeServerCredential)
|
||||
import Simplex.Chat.Badges (badgeServerCredential, defaultFileSizeLimits)
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Library.Commands
|
||||
import Simplex.Chat.Operators
|
||||
@@ -105,10 +105,12 @@ defaultChatConfig =
|
||||
shortLinkPresetServers = allPresetServers,
|
||||
presetDomains = [".simplex.im", ".simplexonflux.com"],
|
||||
tbqSize = 1024,
|
||||
maxChats = 5000,
|
||||
fileChunkSize = 15780, -- do not change
|
||||
xftpDescrPartSize = 14000,
|
||||
inlineFiles = defaultInlineFilesConfig,
|
||||
autoAcceptFileSize = 0,
|
||||
fileSizeLimits = defaultFileSizeLimits,
|
||||
showReactions = False,
|
||||
showFullLinks = False,
|
||||
showReceipts = False,
|
||||
@@ -149,11 +151,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, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, showFullLinks, allowInstantFiles, autoAcceptFileSize}
|
||||
ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, simpleNetCfg, logLevel, logConnections, logServerHosts, logFile, tbqSize, maxChats, deviceName, webPreviewConfig, highlyAvailable, yesToUpMigrations}, optFilesFolder, optTempDirectory, showReactions, showFullLinks, 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, showFullLinks, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, presetServers = presetServers', inlineFiles = inlineFiles', autoAcceptFileSize, webPreviewConfig, highlyAvailable, confirmMigrations = confirmMigrations'}
|
||||
config = cfg {logLevel, showReactions, showFullLinks, tbqSize, maxChats, 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
|
||||
|
||||
+114
-9
@@ -25,7 +25,11 @@ module Simplex.Chat.Badges
|
||||
BBSPublicKeyStr (..),
|
||||
localBadgeInfo,
|
||||
localBadgeStatus,
|
||||
FileSizeLimits (..),
|
||||
defaultFileSizeLimits,
|
||||
maxXFTPFileSize,
|
||||
maxSndXFTPFileSize,
|
||||
badgeSndGraceInterval,
|
||||
badgeServerCredential,
|
||||
maxFileSizeSupporter,
|
||||
maxFileSizeLegend,
|
||||
@@ -46,6 +50,10 @@ module Simplex.Chat.Badges
|
||||
verifyBadge_,
|
||||
mkBadgeStatus,
|
||||
BadgeRow,
|
||||
BadgeProofKind (..),
|
||||
BadgeProofRow,
|
||||
badgeProofToRow,
|
||||
rowToBadgeProof,
|
||||
badgeToRow,
|
||||
localBadgeToRow,
|
||||
rowToBadge,
|
||||
@@ -66,11 +74,13 @@ import Data.String
|
||||
import Data.Text (Text)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, nominalDay)
|
||||
import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
|
||||
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.Crypto.Entitlement (Entitlement (Entitlement), EntitlementCredential (EntitlementCredential), MasterKey (MasterKey), entitlementBBSHeader)
|
||||
import Simplex.Messaging.Encoding (Encoding (..))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
|
||||
#if defined(dbPostgres)
|
||||
@@ -114,6 +124,35 @@ instance FromJSON BadgeType where
|
||||
data BadgeStatus = BSActive | BSExpired | BSExpiredOld | BSFailed | BSUnknownKey
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance TextEncoding BadgeStatus where
|
||||
textEncode = \case
|
||||
BSActive -> "active"
|
||||
BSExpired -> "expired"
|
||||
BSExpiredOld -> "expired_old"
|
||||
BSFailed -> "failed"
|
||||
BSUnknownKey -> "unknown_key"
|
||||
textDecode = \case
|
||||
"active" -> Just BSActive
|
||||
"expired" -> Just BSExpired
|
||||
"expired_old" -> Just BSExpiredOld
|
||||
"failed" -> Just BSFailed
|
||||
"unknown_key" -> Just BSUnknownKey
|
||||
_ -> Nothing
|
||||
|
||||
-- Badge proof kind - a file has at most one proof of each kind
|
||||
|
||||
data BadgeProofKind = BPKInvitation | BPKDescription
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance TextEncoding BadgeProofKind where
|
||||
textEncode = \case
|
||||
BPKInvitation -> "inv"
|
||||
BPKDescription -> "descr"
|
||||
textDecode = \case
|
||||
"inv" -> Just BPKInvitation
|
||||
"descr" -> Just BPKDescription
|
||||
_ -> Nothing
|
||||
|
||||
-- Disclosed badge content (BBS messages 1, 2, 3)
|
||||
|
||||
data BadgeInfo = BadgeInfo
|
||||
@@ -186,10 +225,10 @@ localBadgeStatus = \case
|
||||
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 :: Integer
|
||||
maxFileSizeSupporter = gb 2
|
||||
|
||||
maxFileSizeLegend :: Int64
|
||||
maxFileSizeLegend :: Integer
|
||||
maxFileSizeLegend = gb 5
|
||||
|
||||
badgeServerCredential :: Maybe LocalBadge -> Maybe EntitlementCredential
|
||||
@@ -198,31 +237,62 @@ badgeServerCredential = \case
|
||||
Just $ EntitlementCredential (fromIntegral idx) (MasterKey mk) (Entitlement badgeExpiry (textEncode badgeType) badgeExtra) sig
|
||||
_ -> Nothing
|
||||
|
||||
maxXFTPFileSize :: Maybe LocalBadge -> Int64
|
||||
maxXFTPFileSize = \case
|
||||
Just b | localBadgeStatus b == BSActive -> case badgeType (localBadgeInfo b) of
|
||||
BTLegend -> maxFileSizeLegend
|
||||
_ -> maxFileSizeSupporter
|
||||
_ -> maxFileSize
|
||||
data FileSizeLimits = FileSizeLimits
|
||||
{ noBadge :: Integer,
|
||||
supporter :: Integer,
|
||||
legend :: Integer
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
defaultFileSizeLimits :: FileSizeLimits
|
||||
defaultFileSizeLimits = FileSizeLimits {noBadge = toInteger maxFileSize, supporter = maxFileSizeSupporter, legend = maxFileSizeLegend}
|
||||
|
||||
-- a badge raises the size limit at send for this long after its expiry, shorter than badgeGraceInterval so the receiver still accepts the size
|
||||
badgeSndGraceInterval :: NominalDiffTime
|
||||
badgeSndGraceInterval = nominalDay
|
||||
|
||||
badgeFileSize :: FileSizeLimits -> LocalBadge -> Integer
|
||||
badgeFileSize FileSizeLimits {supporter, legend} b = case badgeType (localBadgeInfo b) of
|
||||
BTLegend -> legend
|
||||
_ -> supporter
|
||||
|
||||
maxXFTPFileSize :: FileSizeLimits -> Maybe LocalBadge -> Integer
|
||||
maxXFTPFileSize lims = \case
|
||||
Just b | localBadgeStatus b == BSActive -> badgeFileSize lims b
|
||||
_ -> noBadge lims
|
||||
|
||||
maxSndXFTPFileSize :: FileSizeLimits -> UTCTime -> Maybe LocalBadge -> Integer
|
||||
maxSndXFTPFileSize lims now = \case
|
||||
Just b | localBadgeStatus b == BSActive && addUTCTime badgeSndGraceInterval (badgeExpiry (localBadgeInfo b)) >= now -> badgeFileSize lims b
|
||||
_ -> noBadge lims
|
||||
|
||||
-- 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 ProofPresHeaderTag = PHTestTag | PHUnknownTag Char
|
||||
data ProofPresHeaderTag = PHTestTag | PHChatTag | PHFileInvTag | PHFileDescrTag | PHUnknownTag Char
|
||||
|
||||
instance StrEncoding ProofPresHeaderTag where
|
||||
strEncode = B.singleton . \case
|
||||
PHTestTag -> 'T'
|
||||
PHChatTag -> 'C'
|
||||
PHFileInvTag -> 'F'
|
||||
PHFileDescrTag -> 'D'
|
||||
PHUnknownTag c -> c
|
||||
strP = tag <$> A.anyChar
|
||||
where
|
||||
tag = \case
|
||||
'T' -> PHTestTag
|
||||
'C' -> PHChatTag
|
||||
'F' -> PHFileInvTag
|
||||
'D' -> PHFileDescrTag
|
||||
c -> PHUnknownTag c
|
||||
|
||||
data ProofPresHeader
|
||||
= PHTest ByteString
|
||||
| PHChat ByteString
|
||||
| PHFileInv {chatBinding :: ByteString, fileSize :: Int64}
|
||||
| PHFileDescr {chatBinding :: ByteString, fileSize :: Int64, descrHash :: ByteString, fileExpires :: Maybe UTCTime}
|
||||
| PHUnknown Char ByteString
|
||||
deriving (Eq, Show)
|
||||
deriving (ToJSON, FromJSON) via (StrJSON "ProofPresHeader" ProofPresHeader)
|
||||
@@ -230,16 +300,31 @@ data ProofPresHeader
|
||||
instance StrEncoding ProofPresHeader where
|
||||
strEncode = \case
|
||||
PHTest nonce -> strEncode PHTestTag <> nonce
|
||||
PHChat binding -> strEncode PHChatTag <> binding
|
||||
PHFileInv {chatBinding, fileSize} ->
|
||||
strEncode PHFileInvTag <> smpEncode (chatBinding, fileSize)
|
||||
PHFileDescr {chatBinding, fileSize, descrHash, fileExpires} ->
|
||||
strEncode PHFileDescrTag <> smpEncode (chatBinding, fileSize, descrHash, utcToSystemTime <$> fileExpires)
|
||||
PHUnknown c b -> strEncode (PHUnknownTag c) <> b
|
||||
strP =
|
||||
strP >>= \case
|
||||
PHTestTag -> PHTest <$> A.takeByteString
|
||||
PHChatTag -> PHChat <$> A.takeByteString
|
||||
PHFileInvTag -> do
|
||||
(chatBinding, fileSize) <- smpP
|
||||
pure PHFileInv {chatBinding, fileSize}
|
||||
PHFileDescrTag -> do
|
||||
(chatBinding, fileSize, descrHash, expires_) <- smpP
|
||||
pure PHFileDescr {chatBinding, fileSize, descrHash, fileExpires = systemToUTCTime <$> expires_}
|
||||
PHUnknownTag c -> PHUnknown c <$> A.takeByteString
|
||||
|
||||
-- v6.5.x accepts both; v7 will reject PHTest/PHUnknown
|
||||
proofPresHeaderAccepted :: ProofPresHeader -> Bool
|
||||
proofPresHeaderAccepted = \case
|
||||
PHTest _ -> True
|
||||
PHChat _ -> True
|
||||
PHFileInv {} -> True
|
||||
PHFileDescr {} -> True
|
||||
PHUnknown _ _ -> True
|
||||
|
||||
-- Payment proof
|
||||
@@ -348,6 +433,26 @@ instance FromField BadgeType where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField BadgeType where toField = toField . textEncode
|
||||
|
||||
instance FromField BadgeStatus where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField BadgeStatus where toField = toField . textEncode
|
||||
|
||||
instance FromField BadgeProofKind where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField BadgeProofKind where toField = toField . textEncode
|
||||
|
||||
-- (proof, pres_header, key_idx, type, expiry, extra) - the fields of BadgeProof as stored in file_badge_proofs
|
||||
type BadgeProofRow = (Binary ByteString, Binary ByteString, Int, Text, UTCTime, Text)
|
||||
|
||||
badgeProofToRow :: BadgeProof -> BadgeProofRow
|
||||
badgeProofToRow (BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}) =
|
||||
(Binary p, Binary ph, idx, textEncode badgeType, badgeExpiry, badgeExtra)
|
||||
|
||||
rowToBadgeProof :: BadgeProofRow -> Maybe BadgeProof
|
||||
rowToBadgeProof (Binary p, Binary ph, idx, type_, badgeExpiry, badgeExtra) = do
|
||||
badgeType <- textDecode type_
|
||||
pure $ BadgeProof idx (BBSPresHeader ph) (BBSProof p) BadgeInfo {badgeType, badgeExpiry, badgeExtra}
|
||||
|
||||
-- (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)
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ 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, LocalBadge)
|
||||
import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge)
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..))
|
||||
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
@@ -97,7 +97,7 @@ import Simplex.Messaging.Session (SessionVar)
|
||||
import Simplex.Messaging.TMap (TMap)
|
||||
import Simplex.Messaging.Transport (TLS, TransportPeer (..), simplexMQVersion)
|
||||
import Simplex.Messaging.Transport.Client (SocksProxyWithAuth, TransportHost)
|
||||
import Simplex.Messaging.Util (AnyError (..), catchAllErrors, (<$$>))
|
||||
import Simplex.Messaging.Util (AnyError (..), catchAllErrors, catchOwn', (<$$>))
|
||||
import Simplex.RemoteControl.Client
|
||||
import Simplex.RemoteControl.Invitation (RCSignedInvitation, RCVerifiedInvitation)
|
||||
import Simplex.RemoteControl.Types
|
||||
@@ -156,10 +156,12 @@ data ChatConfig = ChatConfig
|
||||
shortLinkPresetServers :: NonEmpty SMPServer,
|
||||
presetDomains :: [HostName],
|
||||
tbqSize :: Natural,
|
||||
maxChats :: Int,
|
||||
fileChunkSize :: Integer,
|
||||
xftpDescrPartSize :: Int,
|
||||
inlineFiles :: InlineFilesConfig,
|
||||
autoAcceptFileSize :: Integer,
|
||||
fileSizeLimits :: FileSizeLimits,
|
||||
showReactions :: Bool,
|
||||
showFullLinks :: Bool,
|
||||
showReceipts :: Bool,
|
||||
@@ -397,7 +399,7 @@ data ChatCommand
|
||||
| APISaveAppSettings AppSettings
|
||||
| APIGetAppSettings (Maybe AppSettings)
|
||||
| APIGetChatTags UserId
|
||||
| APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery}
|
||||
| APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: Maybe PaginationByTime, query :: ChatListQuery}
|
||||
| APIGetChat {chatRef :: ChatRef, contentTag :: Maybe MsgContentTag, chatPagination :: ChatPagination, search :: Maybe Text}
|
||||
| APIGetChatContentTypes ChatRef
|
||||
| APIGetChatItems {chatPagination :: ChatPagination, search :: Maybe Text}
|
||||
@@ -677,10 +679,10 @@ data ChatCommand
|
||||
| DeleteRemoteHost RemoteHostId -- Unregister remote host and remove its data
|
||||
| StoreRemoteFile {remoteHostId :: RemoteHostId, storeEncrypted :: Maybe Bool, localPath :: FilePath}
|
||||
| GetRemoteFile {remoteHostId :: RemoteHostId, file :: RemoteFile}
|
||||
| ConnectRemoteCtrl RCSignedInvitation -- Connect new or existing controller via OOB data
|
||||
| ConnectRemoteCtrl {remoteInvitation :: RCSignedInvitation} -- Connect new or existing controller via OOB data
|
||||
| FindKnownRemoteCtrl -- Start listening for announcements from all existing controllers
|
||||
| ConfirmRemoteCtrl RemoteCtrlId -- Confirm the connection with found controller
|
||||
| VerifyRemoteCtrlSession Text -- Verify remote controller session
|
||||
| VerifyRemoteCtrlSession {sessionCode :: Text} -- Verify remote controller session
|
||||
| ListRemoteCtrls
|
||||
| StopRemoteCtrl -- Stop listening for announcements or terminate an active session
|
||||
| DeleteRemoteCtrl RemoteCtrlId -- Remove all local data associated with a remote controller session
|
||||
@@ -896,7 +898,7 @@ data ChatResponse
|
||||
| CRAcceptingContactRequest {user :: User, contact :: Contact}
|
||||
| CRContactAlreadyExists {user :: User, contact :: Contact}
|
||||
| CRLeftMemberUser {user :: User, groupInfo :: GroupInfo}
|
||||
| CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo, msgSigned :: Bool}
|
||||
| CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo, msgSigned :: Bool, localDeletion :: Bool}
|
||||
| CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation}
|
||||
| CRChatMsgContent {user :: User, msgContent :: MsgContent}
|
||||
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
|
||||
@@ -1779,12 +1781,12 @@ withFastStore = withStorePriority True
|
||||
withStorePriority :: Bool -> (DB.Connection -> ExceptT StoreError IO a) -> CM a
|
||||
withStorePriority priority action = do
|
||||
ChatController {chatStore} <- ask
|
||||
liftIOEither $ withTransactionPriority chatStore priority (runExceptT . withExceptT ChatErrorStore . action) `E.catch` handleDBErrors
|
||||
liftIOEither $ withTransactionPriority chatStore priority (runExceptT . withExceptT ChatErrorStore . action) `catchOwn'` handleDBErrors
|
||||
|
||||
withStoreBatch :: Traversable t => (DB.Connection -> t (IO (Either ChatError a))) -> CM' (t (Either ChatError a))
|
||||
withStoreBatch actions = do
|
||||
ChatController {chatStore} <- ask
|
||||
liftIO $ withTransaction chatStore $ mapM (`E.catch` handleDBErrors) . actions
|
||||
liftIO $ withTransaction chatStore $ mapM (`catchOwn'` handleDBErrors) . actions
|
||||
|
||||
handleDBErrors :: E.SomeException -> IO (Either ChatError a)
|
||||
handleDBErrors e = pure $ Left $ ChatErrorStore $ case E.fromException e of
|
||||
|
||||
@@ -59,7 +59,7 @@ import qualified Data.UUID.V4 as V4
|
||||
import Simplex.Chat.Library.Subscriber
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import Simplex.Messaging.Session (SessionVar (..), withGetSessVar')
|
||||
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
|
||||
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType, LocalBadge (..), badgeServerCredential, mkBadgeStatus, maxSndXFTPFileSize, verifyCredential)
|
||||
import qualified Simplex.Chat.Badges.Ledger as L
|
||||
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeState (..))
|
||||
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
|
||||
@@ -71,7 +71,7 @@ import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), Deliv
|
||||
import Simplex.Chat.Files
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages.Batch (encodeBatchElement)
|
||||
import Simplex.Chat.Messages.Batch (BatchMode, encodeBatchElement)
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Messages.CIContent.Events
|
||||
import Simplex.Chat.Operators
|
||||
@@ -132,23 +132,24 @@ import Simplex.RemoteControl.Types (RCCtrlAddress (..))
|
||||
import System.Exit (ExitCode, exitSuccess)
|
||||
import System.FilePath (takeExtension, takeFileName, (</>))
|
||||
import System.IO (Handle, IOMode (..))
|
||||
import System.Mem.Weak (deRefWeak)
|
||||
import System.Random (randomRIO)
|
||||
import System.Timeout (timeout)
|
||||
import UnliftIO.Async
|
||||
import UnliftIO.Concurrent (forkIO, threadDelay)
|
||||
import UnliftIO.Concurrent (forkIO, killThread, threadDelay)
|
||||
import UnliftIO.Directory
|
||||
import qualified UnliftIO.Exception as E
|
||||
import UnliftIO.IO (hClose)
|
||||
import UnliftIO.STM
|
||||
#if defined(dbPostgres)
|
||||
import Data.Bifunctor (bimap, first, second)
|
||||
import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError)
|
||||
import Simplex.Messaging.Agent.Client (SubInfo (..), cancelWorker, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError)
|
||||
#else
|
||||
import Data.Bifunctor (bimap, first, second)
|
||||
import qualified Data.ByteArray as BA
|
||||
import qualified Database.SQLite.Simple as SQL
|
||||
import Simplex.Chat.Archive
|
||||
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError)
|
||||
import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, cancelWorker, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError)
|
||||
import Simplex.Messaging.Agent.Store.Common (withConnection)
|
||||
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
|
||||
#endif
|
||||
@@ -173,7 +174,7 @@ checkProfileImageSize = mapM_ $ \(ImageData t) ->
|
||||
in when (size > maxProfileImageSize) $ throwCmdError $ "Profile image is too large " <> show size
|
||||
|
||||
checkProfileSize :: Profile -> CM ()
|
||||
checkProfileSize p = checkInfoSize "Profile" (XInfo p)
|
||||
checkProfileSize p = checkInfoSize "Profile" (XInfo p Nothing)
|
||||
|
||||
checkGroupProfileSize :: GroupProfile -> CM ()
|
||||
checkGroupProfileSize p = checkInfoSize "Group profile" (XGrpInfo p)
|
||||
@@ -356,12 +357,20 @@ restoreCalls = do
|
||||
atomically $ writeTVar calls callsMap
|
||||
|
||||
stopChatController :: ChatController -> IO ()
|
||||
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, badgeWorkers} = do
|
||||
stopBadgeWorkers badgeWorkers
|
||||
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, cleanupManagerAsync, relayGroupLinkChecksAsync, webPreviewState, expireCIThreads, timedItemThreads, deliveryTaskWorkers, deliveryJobWorkers, relayRequestWorkers, badgeWorkers} = do
|
||||
readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd)
|
||||
atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd)
|
||||
disconnectAgentClient smpAgent
|
||||
readTVarIO s >>= mapM_ (\(a1, a2) -> forkIO $ uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2)
|
||||
readTVarIO s >>= mapM_ (\(a1, a2) -> uninterruptibleCancel a1 >> mapM_ uninterruptibleCancel a2)
|
||||
cancelAsync cleanupManagerAsync
|
||||
cancelAsync relayGroupLinkChecksAsync
|
||||
forM_ webPreviewState $ \WebPreviewState {webPreviewWorkerAsync} -> cancelAsync webPreviewWorkerAsync
|
||||
clearMap expireCIThreads >>= mapM_ (mapM_ uninterruptibleCancel)
|
||||
clearMap timedItemThreads >>= mapM_ (readTVarIO >=> mapM_ (deRefWeak >=> mapM_ killThread))
|
||||
clearMap deliveryTaskWorkers >>= mapM_ cancelWorker
|
||||
clearMap deliveryJobWorkers >>= mapM_ cancelWorker
|
||||
clearMap relayRequestWorkers >>= mapM_ cancelWorker
|
||||
stopBadgeWorkers badgeWorkers
|
||||
closeFiles sndFiles
|
||||
closeFiles rcvFiles
|
||||
atomically $ do
|
||||
@@ -369,6 +378,10 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles,
|
||||
forM_ keys $ \k -> TM.insert k False expireCIFlags
|
||||
writeTVar s Nothing
|
||||
where
|
||||
cancelAsync :: TVar (Maybe (Async ())) -> IO ()
|
||||
cancelAsync a = atomically (swapTVar a Nothing) >>= mapM_ uninterruptibleCancel
|
||||
clearMap :: TM.TMap k a -> IO (Map k a)
|
||||
clearMap m = atomically $ swapTVar m M.empty
|
||||
closeFiles :: TVar (Map Int64 Handle) -> IO ()
|
||||
closeFiles files = do
|
||||
fs <- readTVarIO files
|
||||
@@ -664,7 +677,9 @@ processChatCommand cxt nm = \case
|
||||
tags <- withFastStore' (`getUserChatTags` user)
|
||||
pure $ CRChatTags user tags
|
||||
APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do
|
||||
(errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user pendingConnections pagination query)
|
||||
ChatConfig {maxChats} <- asks config
|
||||
let pagination' = fromMaybe (PTLast maxChats) pagination
|
||||
(errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user pendingConnections pagination' query)
|
||||
unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs)
|
||||
pure $ CRApiChats user previews
|
||||
APIGetChat (ChatRef cType cId scope_) contentFilter pagination search -> withUser $ \user -> case cType of
|
||||
@@ -1217,7 +1232,7 @@ processChatCommand cxt nm = \case
|
||||
Nothing -> throwCmdError "not a public group"
|
||||
Just PublicGroupProfile {groupLink} -> do
|
||||
let signingKeys = case (memberRole, groupKeys) of
|
||||
(GROwner, Just gk@GroupKeys {groupRootKey = GRKPrivate _}) -> Just gk
|
||||
(GROwner, Just gk@GroupKeys {publicGroupKeys = Just PublicGroupKeys {groupRootKey = GRKPrivate _}}) -> Just gk
|
||||
_ -> Nothing
|
||||
ownerSig <-
|
||||
pure signingKeys $>>= \GroupKeys {memberPrivKey} ->
|
||||
@@ -1385,7 +1400,7 @@ processChatCommand cxt nm = \case
|
||||
withFastStore' $ \db -> cleanupHostGroupLinkConn db user gInfo
|
||||
withFastStore' $ \db -> deleteGroupMembers db user gInfo
|
||||
withFastStore' $ \db -> deleteGroup db user gInfo
|
||||
pure $ CRGroupDeletedUser user gInfo msgSigned
|
||||
pure $ CRGroupDeletedUser user gInfo msgSigned (not doSendDel)
|
||||
where
|
||||
getRecipients gInfo
|
||||
| useRelays' gInfo = do
|
||||
@@ -2298,8 +2313,7 @@ processChatCommand cxt nm = \case
|
||||
-- set group link info and incognito profile, generate and store membership keys
|
||||
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
|
||||
let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} e2e
|
||||
gVar <- asks random
|
||||
(_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar
|
||||
(_, memberPrivKey) <- atomically . C.generateKeyPair =<< asks random
|
||||
gInfo' <- withFastStore $ \db -> do
|
||||
gInfo' <- updatePreparedRelayedGroup db cxt user gInfo mainCReq cReqHash incognitoProfile rootKey memberPrivKey publicMemberCount_
|
||||
-- Pre-emptively create owner members with trusted keys from link data
|
||||
@@ -2441,8 +2455,7 @@ processChatCommand cxt nm = \case
|
||||
Left e -> throwError $ ChatErrorStore e
|
||||
Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
gVar <- asks random
|
||||
rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar
|
||||
rootKey@(rootPubKey, rootPrivKey) <- atomically . C.generateKeyPair =<< asks random
|
||||
let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey
|
||||
-- TODO [address DR] remove this option and switch to IKUsePQ True
|
||||
let (pqInitKeys, useDR) = case pqRatchet_ of
|
||||
@@ -2685,7 +2698,8 @@ processChatCommand cxt nm = \case
|
||||
APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do
|
||||
g <- asks random
|
||||
memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12
|
||||
gInfo <- newGroup user incognito gProfile False memberId Nothing Nothing
|
||||
(_, memberPrivKey) <- atomically $ C.generateKeyPair g
|
||||
gInfo <- newGroup user incognito gProfile False memberId (Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey}) Nothing
|
||||
createNewGroupItems user gInfo
|
||||
pure $ CRGroupCreated user gInfo
|
||||
NewGroup incognito gProfile -> withUser $ \User {userId} ->
|
||||
@@ -2721,7 +2735,7 @@ processChatCommand cxt nm = \case
|
||||
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
-- generate root key pair; entity ID = sha256(rootPubKey) — see docs/rfcs/2026-03-28-group-identity-binding.md
|
||||
rootKey@(rootPubKey, rootPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar
|
||||
rootKey@(rootPubKey, rootPrivKey) <- atomically $ C.generateKeyPair gVar
|
||||
let entityId = C.sha256Hash $ C.pubKeyBytes rootPubKey
|
||||
crClientData = encodeJSON $ CRDataGroup groupLinkId
|
||||
-- prepare link with entityId as linkEntityId (no server request)
|
||||
@@ -2739,7 +2753,8 @@ processChatCommand cxt nm = \case
|
||||
userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData, ratchetKeys = Nothing}
|
||||
-- create connection with prepared link (single network call)
|
||||
connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData subMode
|
||||
let groupKeys = GroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey}
|
||||
let groupKeys = GroupKeys {publicGroupKeys, memberPrivKey}
|
||||
publicGroupKeys = Just PublicGroupKeys {publicGroupId = B64UrlByteString entityId, groupRootKey = GRKPrivate rootPrivKey}
|
||||
setupLink gInfo = do
|
||||
-- TODO [relays] starting role should be communicated in protocol from owner to relays
|
||||
subRole <- asks $ channelSubscriberRole . config
|
||||
@@ -2828,7 +2843,7 @@ processChatCommand cxt nm = \case
|
||||
case activeConn of
|
||||
Just Connection {peerChatVRange} -> do
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
|
||||
dm <- encodeConnInfo $ XGrpAcpt membershipMemId (groupMemberKey g)
|
||||
agentConnId <- case memberConn fromMember of
|
||||
Nothing -> do
|
||||
agentConnId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True connRequest PQSupportOff
|
||||
@@ -3393,7 +3408,7 @@ processChatCommand cxt nm = \case
|
||||
joinPreparedConn subMode conn = do
|
||||
-- [incognito] send membership incognito profile
|
||||
p <- presentUserBadge user (incognitoMembershipProfile gInfo) $ userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile gInfo) Nothing True
|
||||
dm <- encodeConnInfo $ XInfo p
|
||||
dm <- encodeConnInfo $ XInfo p Nothing
|
||||
sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode
|
||||
let newStatus = if sqSecured then ConnSndReady else ConnJoined
|
||||
void $ withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus
|
||||
@@ -3425,7 +3440,8 @@ processChatCommand cxt nm = \case
|
||||
folderId <- withFastStore (`getUserNoteFolderId` user)
|
||||
processChatCommand cxt nm $ APIClearChat (ChatRef CTLocal folderId Nothing)
|
||||
LastChats count_ -> withUser' $ \user -> do
|
||||
let count = fromMaybe 5000 count_
|
||||
ChatConfig {maxChats} <- asks config
|
||||
let count = fromMaybe maxChats count_
|
||||
(errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db cxt user False (PTLast count) clqNoFilters)
|
||||
unless (null errs) $ toView $ CEvtChatErrors (map ChatErrorStore errs)
|
||||
pure $ CRChats previews
|
||||
@@ -3632,7 +3648,7 @@ processChatCommand cxt nm = \case
|
||||
fsFilePath <- lift $ toFSFilePath filePath
|
||||
fileSize <- liftIO $ CF.getFileContentsSize file {filePath = fsFilePath}
|
||||
when (fileSize > toInteger maxFileSizeHard) $ throwChatError $ CEFileSize filePath
|
||||
(_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing
|
||||
(_, _, fileTransferMeta) <- xftpSndFileTransfer_ user file fileSize 1 Nothing Nothing
|
||||
pure CRSndStandaloneFileCreated {user, fileTransferMeta}
|
||||
APIStandaloneFileInfo FileDescriptionURI {clientData} -> pure . CRStandaloneFileInfo $ clientData >>= J.decodeStrict . encodeUtf8
|
||||
APIDownloadStandaloneFile userId uri file -> withUserId userId $ \user -> do
|
||||
@@ -3808,7 +3824,7 @@ processChatCommand cxt nm = \case
|
||||
joinPreparedConn conn incognitoProfile
|
||||
joinPreparedConn conn incognitoProfile = do
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user incognitoProfile Nothing True
|
||||
dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend
|
||||
dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend Nothing
|
||||
sqSecured <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup' subMode
|
||||
let newStatus = if sqSecured then ConnSndReady else ConnJoined
|
||||
conn' <- withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared newStatus
|
||||
@@ -3961,10 +3977,15 @@ processChatCommand cxt nm = \case
|
||||
Just gInfo_' -> userProfileInGroup' user gInfo_' 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 $ XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_
|
||||
Just (Just gInfo)
|
||||
| useRelays' gInfo -> case relayMemberId_ of
|
||||
Just relayMemberId -> encodeXMemberConnInfo gInfo relayMemberId profileToSend
|
||||
Nothing -> throwChatError $ CEInternalError "relay group join without target relay memberId"
|
||||
| otherwise -> do
|
||||
gInfo' <- createUserMemberKey gInfo
|
||||
encodeConnInfoPQ pqSup $ XContact profileToSend (groupMemberKey gInfo') (Just xContactId) welcomeSharedMsgId msg_
|
||||
_ ->
|
||||
encodeConnInfoPQ pqSup $ XContact profileToSend Nothing (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
|
||||
@@ -3977,7 +3998,9 @@ processChatCommand cxt nm = \case
|
||||
fsFilePath <- lift $ toFSFilePath f
|
||||
unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f
|
||||
fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cfArgs
|
||||
when (fromInteger fileSize > maxXFTPFileSize sndBadge) $ throwChatError $ CEFileSize f
|
||||
lims <- asks $ fileSizeLimits . config
|
||||
now <- liftIO getCurrentTime
|
||||
when (fileSize > maxSndXFTPFileSize lims now sndBadge) $ throwChatError $ CEFileSize f
|
||||
pure fileSize
|
||||
updateProfile :: User -> Profile -> CM ChatResponse
|
||||
updateProfile user p' = updateProfile_ user p' True $ withFastStore $ \db -> updateUserProfile db user p'
|
||||
@@ -4039,7 +4062,7 @@ processChatCommand cxt nm = \case
|
||||
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'')
|
||||
pure (ConnectionId connId, Nothing, XInfo p'' Nothing)
|
||||
ctMsgReq :: ChangedProfileContact -> Either ChatError SndMessage -> Either ChatError ChatMsgReq
|
||||
ctMsgReq ChangedProfileContact {conn} =
|
||||
fmap $ \SndMessage {msgId, msgBody} ->
|
||||
@@ -4072,7 +4095,7 @@ processChatCommand cxt nm = \case
|
||||
when (mergedProfile' /= mergedProfile) $
|
||||
withContactLock "updateContactPrefs" (contactId' ct) $ do
|
||||
p <- presentUserBadge user incognitoProfile mergedProfile'
|
||||
void (sendDirectContactMessage user ct' $ XInfo p) `catchAllErrors` eToView
|
||||
void (sendDirectContactMessage user ct' $ XInfo p Nothing) `catchAllErrors` eToView
|
||||
lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct'
|
||||
pure $ CRContactPrefsUpdated user ct ct'
|
||||
runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> Bool -> CM ChatResponse
|
||||
@@ -4248,12 +4271,13 @@ processChatCommand cxt nm = \case
|
||||
createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing
|
||||
createGroupFeatureItems user cd CISndGroupFeature gInfo
|
||||
sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM ()
|
||||
sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do
|
||||
sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} m@GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do
|
||||
let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo
|
||||
GroupMember {memberRole = userRole, memberId = userMemberId} = membership
|
||||
groupInv =
|
||||
GroupInvitation
|
||||
{ fromMember = MemberIdRole userMemberId userRole,
|
||||
fromMemberKey = groupMemberKey gInfo,
|
||||
invitedMember = MemberIdRole memberId memRole,
|
||||
connRequest = cReq,
|
||||
groupProfile,
|
||||
@@ -4772,7 +4796,8 @@ processChatCommand cxt nm = \case
|
||||
Just file -> do
|
||||
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
|
||||
binding_ <- ifM ((not (contactConnIncognito ct) &&) <$> fileNeedsBadge fileSize) (directChatBinding ct) (pure Nothing)
|
||||
(fInv, ciFile) <- xftpSndFileTransfer user file fileSize 1 (CGContact ct) binding_
|
||||
pure (Just fInv, Just ciFile)
|
||||
Nothing -> pure (Nothing, Nothing)
|
||||
prepareMsgs :: NonEmpty (ComposedMessageReq, Maybe FileInvitation) -> Maybe CITimed -> CM (NonEmpty (MsgContainer, Maybe (CIQuote 'CTDirect)))
|
||||
@@ -4803,8 +4828,10 @@ processChatCommand cxt nm = \case
|
||||
sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL sign cmrs = do
|
||||
assertMultiSendable live cmrs
|
||||
chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope
|
||||
recipients <- getGroupRecipients cxt user gInfo chatScopeInfo modsCompatVersion
|
||||
sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs
|
||||
-- the member key is created before the send, so that signatures and file badge proofs assert the same key
|
||||
gInfo' <- createUserMemberKey gInfo
|
||||
recipients <- getGroupRecipients cxt user gInfo' chatScopeInfo modsCompatVersion
|
||||
sendGroupContentMessages_ user gInfo' scope showGroupAsSender chatScopeInfo recipients live itemTTL sign cmrs
|
||||
where
|
||||
hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs
|
||||
modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion
|
||||
@@ -4857,7 +4884,9 @@ processChatCommand cxt nm = \case
|
||||
Just file -> do
|
||||
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
|
||||
needsBadge <- fileNeedsBadge fileSize
|
||||
let binding_ = if needsBadge && not (incognitoMembership gInfo) then sndGroupChatBinding gInfo showGroupAsSender else Nothing
|
||||
(fInv, ciFile) <- xftpSndFileTransfer user file fileSize n (CGGroup gInfo recipients) binding_
|
||||
fInv' <-
|
||||
if signMsgs && useRelays' gInfo
|
||||
then (\d -> (fInv :: FileInvitation) {fileDigest = Just d}) <$> cryptoFileDigest file
|
||||
@@ -4915,9 +4944,9 @@ processChatCommand cxt nm = \case
|
||||
-- batching retrieval of quoted messages (prepareMsgs).
|
||||
when (live || length (L.filter (\(ComposedMessage {quotedItemId}, _, _, _) -> isJust quotedItemId) cmrs) > 1) $
|
||||
throwCmdError "invalid multi send: live and more than one quote not supported"
|
||||
xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd)
|
||||
xftpSndFileTransfer user file fileSize n contactOrGroup = do
|
||||
(fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup
|
||||
xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> Maybe ByteString -> CM (FileInvitation, CIFile 'MDSnd)
|
||||
xftpSndFileTransfer user file fileSize n contactOrGroup binding_ = do
|
||||
(fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n (Just contactOrGroup) binding_
|
||||
case contactOrGroup of
|
||||
CGContact Contact {activeConn} -> forM_ activeConn $ \conn ->
|
||||
withFastStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr
|
||||
@@ -5007,7 +5036,7 @@ processChatCommand cxt nm = \case
|
||||
chunkSize <- asks $ fileChunkSize . config
|
||||
withFastStore' $ \db -> do
|
||||
fileId <- createLocalFile CIFSSndStored db user nf createdAt cf fileSize chunkSize
|
||||
pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal, fileExpires = Nothing}
|
||||
pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal, fileExpires = Nothing, fileProhibited = Nothing}
|
||||
prepareLocalItemsData ::
|
||||
NonEmpty ComposedMessageReq ->
|
||||
NonEmpty (Maybe (CIFile 'MDSnd)) ->
|
||||
@@ -5175,7 +5204,7 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg
|
||||
| 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
|
||||
void (sendDirectContactMessage user' ct' (XInfo p Nothing)) `catchAllErrors` eToView
|
||||
_ -> pure ()
|
||||
|
||||
-- | The check character is verified before anything leaves the device, and the signing keys are
|
||||
@@ -5955,7 +5984,7 @@ chatCommandP =
|
||||
*> ( APIGetChats
|
||||
<$> A.decimal
|
||||
<*> (" pcc=on" $> True <|> " pcc=off" $> False <|> pure False)
|
||||
<*> (A.space *> paginationByTimeP <|> pure (PTLast 5000))
|
||||
<*> optional (A.space *> paginationByTimeP)
|
||||
<*> (A.space *> jsonP <|> pure clqNoFilters)
|
||||
),
|
||||
"/_get chat " *> (APIGetChat <$> chatRefP <*> optional (" content=" *> strP) <* A.space <*> chatPaginationP <*> optional (" search=" *> textP)),
|
||||
|
||||
@@ -40,7 +40,7 @@ import Data.Foldable (foldr')
|
||||
import Data.Functor (($>))
|
||||
import Data.Functor.Identity
|
||||
import Data.Int (Int64)
|
||||
import Data.List (foldl', mapAccumL, partition)
|
||||
import Data.List (find, foldl', mapAccumL, partition)
|
||||
import Data.List.NonEmpty (NonEmpty (..), (<|))
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Map.Strict (Map)
|
||||
@@ -53,7 +53,7 @@ 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 (..), ProofPresHeader (..), BadgeProof (..), BadgeStatus (..), LocalBadge (..), badgeProof, mkBadgeStatus, verifyBadge)
|
||||
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), ProofPresHeader (..), BadgeProof (..), BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), LocalBadge (..), badgeProof, badgeSndGraceInterval, generateBadgeProof, localBadgeStatus, maxXFTPFileSize, mkBadgeStatus, verifyBadge)
|
||||
import Simplex.Chat.Names (SimplexDomainClaim (..), claimDomain)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller
|
||||
@@ -98,7 +98,8 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn)
|
||||
import qualified Simplex.Messaging.Crypto.Ratchet as CR
|
||||
import Simplex.Messaging.Encoding (smpEncode)
|
||||
import Simplex.Messaging.Crypto.BBS (BBSPresHeader (..))
|
||||
import Simplex.Messaging.Encoding (smpDecode, smpEncode)
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Protocol (MsgBody, MsgFlags (..), ProtoServerWithAuth (..), ProtocolServer, ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer)
|
||||
import qualified Simplex.Messaging.Protocol as SMP
|
||||
@@ -435,10 +436,11 @@ roundedFDCount n
|
||||
| n <= 0 = 4
|
||||
| otherwise = max 4 $ fromIntegral $ (2 :: Integer) ^ (ceiling (logBase 2 (fromIntegral n) :: Double) :: Integer)
|
||||
|
||||
xftpSndFileTransfer_ :: User -> CryptoFile -> Integer -> Int -> Maybe ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
|
||||
xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup_ = do
|
||||
xftpSndFileTransfer_ :: User -> CryptoFile -> Integer -> Int -> Maybe ContactOrGroup -> Maybe ByteString -> CM (FileInvitation, CIFile 'MDSnd, FileTransferMeta)
|
||||
xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOrGroup_ binding_ = do
|
||||
fileBadge <- pure binding_ $>>= \chatBinding -> sndBadgeProof user PHFileInv {chatBinding, fileSize = fromInteger fileSize}
|
||||
let fileName = takeFileName filePath
|
||||
fInv = xftpFileInvitation fileName fileSize dummyFileDescr
|
||||
fInv = (xftpFileInvitation fileName fileSize dummyFileDescr :: FileInvitation) {fileBadge}
|
||||
fsFilePath <- lift $ toFSFilePath filePath
|
||||
let srcFile = CryptoFile fsFilePath cfArgs
|
||||
aFileId <- withAgent $ \a -> xftpSendFile a (aUserId user) srcFile (roundedFDCount n) Nothing
|
||||
@@ -446,9 +448,32 @@ xftpSndFileTransfer_ user file@(CryptoFile filePath cfArgs) fileSize n contactOr
|
||||
chSize <- asks $ fileChunkSize . config
|
||||
ft@FileTransferMeta {fileId} <- withStore' $ \db -> createSndFileTransferXFTP db user contactOrGroup_ file fInv (AgentSndFileId aFileId) Nothing chSize
|
||||
let fileSource = Just $ CryptoFile filePath cfArgs
|
||||
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing}
|
||||
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP, fileExpires = Nothing, fileProhibited = Nothing}
|
||||
pure (fInv, ciFile, ft)
|
||||
|
||||
fileNeedsBadge :: Integer -> CM Bool
|
||||
fileNeedsBadge fileSize = (fileSize >) . noBadge <$> asks (fileSizeLimits . config)
|
||||
|
||||
sndBadgeProof :: User -> ProofPresHeader -> CM (Maybe BadgeProof)
|
||||
sndBadgeProof user = sndBadgeProof_ user . BBSPresHeader . strEncode
|
||||
|
||||
sndBadgeProof_ :: User -> BBSPresHeader -> CM (Maybe BadgeProof)
|
||||
sndBadgeProof_ User {profile = LocalProfile {localBadge}} ph = case localBadge of
|
||||
Just (OwnBadge cred@(BadgeCredential keyIdx _ _ _) _) -> do
|
||||
keys <- asks $ badgePublicKeys . config
|
||||
case M.lookup keyIdx keys of
|
||||
Nothing -> Nothing <$ logError "sndBadgeProof: badge key index not in config"
|
||||
Just key ->
|
||||
liftIO (generateBadgeProof key cred ph) >>= \case
|
||||
Right proof -> pure $ Just proof
|
||||
Left e -> Nothing <$ logError ("sndBadgeProof: proof generation failed: " <> T.pack e)
|
||||
_ -> pure Nothing
|
||||
|
||||
sndGroupChatBinding :: GroupInfo -> ShowGroupAsSender -> Maybe ByteString
|
||||
sndGroupChatBinding GroupInfo {groupKeys, membership = GroupMember {memberId}} asGroup
|
||||
| asGroup = (\PublicGroupKeys {publicGroupId} -> encodeChatBinding CBChannel $ smpEncode publicGroupId) <$> (groupKeys >>= publicGroupKeys)
|
||||
| otherwise = (\GroupKeys {memberPrivKey} -> encodeChatBinding CBGroup $ groupBindingData groupKeys memberId (C.publicKey memberPrivKey)) <$> groupKeys
|
||||
|
||||
cryptoFileDigest :: CryptoFile -> CM FD.FileDigest
|
||||
cryptoFileDigest (CryptoFile filePath cfArgs) = do
|
||||
fsPath <- lift $ toFSFilePath filePath
|
||||
@@ -744,10 +769,11 @@ rctFileCancelled = \case
|
||||
_ -> False
|
||||
|
||||
acceptFileReceive :: User -> RcvFileTransfer -> Bool -> Maybe Bool -> Maybe FilePath -> CM AChatItem
|
||||
acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = FileInvitation {fileName = fName, fileConnReq, fileInline, fileSize}, fileStatus, grpMemberId, cryptoArgs} userApprovedRelays rcvInline_ filePath_ = do
|
||||
acceptFileReceive user@User {userId} RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = FileInvitation {fileName = fName, fileConnReq, fileInline, fileSize}, fileProhibited, fileStatus, grpMemberId, cryptoArgs} userApprovedRelays rcvInline_ filePath_ = do
|
||||
unless (fileStatus == RFSNew) $ case fileStatus of
|
||||
RFSCancelled _ -> throwChatError $ CEFileCancelled fName
|
||||
_ -> throwChatError $ CEFileAlreadyReceiving fName
|
||||
when (isJust fileProhibited) $ throwChatError $ CEFileSize fName
|
||||
cxt <- chatStoreCxt
|
||||
case (xftpRcvFile, fileConnReq) of
|
||||
-- XFTP
|
||||
@@ -970,7 +996,7 @@ acceptContactRequest nm user@User {userId} UserContactRequest {agentInvitationId
|
||||
incognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId
|
||||
pure (ct, conn, ExistingIncognito <$> incognitoProfile)
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True
|
||||
dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend
|
||||
dm <- encodeConnInfoPQ pqSup' $ XInfo profileToSend Nothing
|
||||
(ct,conn,) <$> withAgent (\a -> acceptContact a nm (aUserId user) (aConnId conn) True invId dm pqSup' subMode)
|
||||
|
||||
acceptContactRequestAsync :: User -> Int64 -> Contact -> UserContactRequest -> Maybe IncognitoProfile -> CM Contact
|
||||
@@ -991,7 +1017,7 @@ acceptContactRequestAsync
|
||||
Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs
|
||||
liftIO $ setCommandConnId db user cmdId connId
|
||||
getContact db cxt user contactId
|
||||
agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend) cReqPQSup subMode
|
||||
agentAcceptContactAsync cmdId acId True cReqInvId (XInfo profileToSend Nothing) cReqPQSup subMode
|
||||
pure ct'
|
||||
|
||||
acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> Maybe GroupMember -> CM GroupMember
|
||||
@@ -1032,6 +1058,7 @@ acceptGroupJoinRequestAsync
|
||||
GroupLinkInvitation
|
||||
{ fromMember = MemberIdRole userMemberId userRole,
|
||||
fromMemberName = displayName,
|
||||
fromMemberKey = groupMemberKey gInfo,
|
||||
invitedMember = MemberIdRole memberId gLinkMemRole,
|
||||
groupProfile,
|
||||
accepted = Just gAccepted,
|
||||
@@ -1095,6 +1122,7 @@ acceptBusinessJoinRequestAsync
|
||||
GroupLinkInvitation
|
||||
{ fromMember = MemberIdRole userMemberId userRole,
|
||||
fromMemberName = displayName,
|
||||
fromMemberKey = groupMemberKey gInfo,
|
||||
invitedMember = MemberIdRole memberId GRMember,
|
||||
groupProfile = businessGroupProfile userProfile groupPreferences,
|
||||
accepted = Just GAAccepted,
|
||||
@@ -1417,15 +1445,17 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
|
||||
resolveAuthor (Just gmId) = do
|
||||
cxt <- chatStoreCxt
|
||||
eitherToMaybe <$> withStore' (\db -> runExceptT $ getGroupMemberById db cxt user gmId)
|
||||
getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime))
|
||||
getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe HistoryFile)
|
||||
getRcvFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus, fileExpires} = do
|
||||
expired <- fileExpired fileExpires
|
||||
if fileProtocol /= FPXFTP || fileStatus == CIFSRcvCancelled || expired
|
||||
then pure Nothing
|
||||
else do
|
||||
rfd <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId
|
||||
pure $ invCompleteDescr ciFile rfd
|
||||
getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime))
|
||||
(rfd, (invBadge, descrBadge)) <- withStore $ \db -> do
|
||||
rfd <- getRcvFileDescrByRcvFileId db fileId
|
||||
(rfd,) <$> liftIO (getFileBadgeProofs db fileId)
|
||||
pure $ invCompleteDescr ciFile rfd invBadge descrBadge
|
||||
getSndFileInvDescr :: CIFile 'MDSnd -> CM (Maybe HistoryFile)
|
||||
getSndFileInvDescr ciFile@CIFile {fileId, fileProtocol, fileStatus, fileExpires} = do
|
||||
expired <- fileExpired fileExpires
|
||||
if fileProtocol /= FPXFTP || fileStatus == CIFSSndCancelled || expired
|
||||
@@ -1433,28 +1463,54 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
|
||||
else do
|
||||
-- can also lookup in extra_xftp_file_descriptions, though it can be empty;
|
||||
-- would be best if snd file had a single rcv description for all members saved in files table
|
||||
rfd <- withStore $ \db -> getRcvFileDescrBySndFileId db fileId
|
||||
pure $ invCompleteDescr ciFile rfd
|
||||
now <- liftIO getCurrentTime
|
||||
(rfd, (invBadge, descrBadge)) <- withStore $ \db -> do
|
||||
rfd <- getRcvFileDescrBySndFileId db fileId
|
||||
(rfd,) <$> liftIO (getFileBadgeProofs db fileId)
|
||||
-- a signed item forwards the author's original bytes, so its invitation proof cannot be replaced
|
||||
(invBadge', descrBadge') <-
|
||||
if isNothing signedMsg_ && ownBadgeActive && (staleBadge now invBadge || staleBadge now descrBadge)
|
||||
then refreshSndBadges fileId invBadge descrBadge
|
||||
else pure (invBadge, descrBadge)
|
||||
pure $ invCompleteDescr ciFile rfd invBadge' descrBadge'
|
||||
staleBadge :: UTCTime -> Maybe BadgeProof -> Bool
|
||||
staleBadge now = \case
|
||||
Just BadgeProof {badgeInfo = BadgeInfo {badgeExpiry}} -> addUTCTime badgeSndGraceInterval badgeExpiry < now
|
||||
Nothing -> False
|
||||
ownBadgeActive :: Bool
|
||||
ownBadgeActive = maybe False ((BSActive ==) . localBadgeStatus) localBadge
|
||||
where
|
||||
User {profile = LocalProfile {localBadge}} = user
|
||||
-- both proofs are made with the same badge, and are re-made with the current badge over the stored headers
|
||||
refreshSndBadges :: FileTransferId -> Maybe BadgeProof -> Maybe BadgeProof -> CM (Maybe BadgeProof, Maybe BadgeProof)
|
||||
refreshSndBadges fileId invBadge descrBadge = do
|
||||
invBadge' <- mapM reProve invBadge
|
||||
descrBadge' <- mapM reProve descrBadge
|
||||
withStore' $ \db -> do
|
||||
forM_ invBadge' $ createFileBadgeProof db fileId BPKInvitation
|
||||
forM_ descrBadge' $ createFileBadgeProof db fileId BPKDescription
|
||||
pure (invBadge', descrBadge')
|
||||
where
|
||||
reProve badge@BadgeProof {presHeader} = fromMaybe badge <$> sndBadgeProof_ user presHeader
|
||||
fileExpired :: Maybe UTCTime -> CM Bool
|
||||
fileExpired fileExpires = do
|
||||
ttl <- asks $ rcvFilesTTL . agentConfig . config
|
||||
now <- liftIO getCurrentTime
|
||||
pure $ fromMaybe (addUTCTime ttl $ chatItemTs cci) fileExpires < now
|
||||
invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime)
|
||||
invCompleteDescr CIFile {fileName, fileSize, fileExpires} RcvFileDescr {fileDescrText, fileDescrComplete}
|
||||
invCompleteDescr :: CIFile d -> RcvFileDescr -> Maybe BadgeProof -> Maybe BadgeProof -> Maybe HistoryFile
|
||||
invCompleteDescr CIFile {fileName, fileSize, fileExpires} RcvFileDescr {fileDescrText, fileDescrComplete} invBadge descrBadge
|
||||
| fileDescrComplete =
|
||||
let fInvDescr = FileDescr {fileDescrText = "", fileDescrPartNo = 0, fileDescrComplete = False}
|
||||
fInv = xftpFileInvitation fileName fileSize fInvDescr
|
||||
in Just (fInv, fileDescrText, fileExpires)
|
||||
let fInv = (xftpFileInvitation fileName fileSize dummyFileDescr :: FileInvitation) {fileBadge = invBadge}
|
||||
in Just (fInv, fileDescrText, fileExpires, descrBadge)
|
||||
| otherwise = Nothing
|
||||
processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText, Maybe UTCTime) -> CM [(GrpMsgForward, VerifiedMsg 'Json)]
|
||||
processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe HistoryFile -> CM [(GrpMsgForward, VerifiedMsg 'Json)]
|
||||
processContentItem member_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ =
|
||||
if isNothing fInvDescr_ && not (msgContentHasText mc)
|
||||
then pure []
|
||||
else do
|
||||
let CIMeta {itemTs, itemSharedMsgId, itemTimed, showGroupAsSender} = meta
|
||||
quotedItemId_ = quoteItemId =<< quotedItem
|
||||
fInv_ = (\(fInv, _, _) -> fInv) <$> fInvDescr_
|
||||
fInv_ = (\(fInv, _, _, _) -> fInv) <$> fInvDescr_
|
||||
(mc', _, mentions') = updatedMentionNames mc formattedText mentions
|
||||
mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions'
|
||||
-- for channel messages default chat version range to membership range
|
||||
@@ -1473,10 +1529,10 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c
|
||||
(chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing showGroupAsSender mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False
|
||||
pure $ VMUnsigned ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent}
|
||||
fileDescrEvents <- case (fInvDescr_, itemSharedMsgId) of
|
||||
(Just (_, fileDescrText, fileExpires), Just msgId) -> do
|
||||
(Just (_, fileDescrText, fileExpires, descrBadge), Just msgId) -> do
|
||||
partSize <- asks $ xftpDescrPartSize . config
|
||||
let parts = splitFileDescr partSize fileDescrText
|
||||
pure . L.toList $ L.map (\fd -> XMsgFileDescr msgId fd fileExpires) parts
|
||||
let parts = splitFileDescr partSize (maybe partSize (const badgeDescrPartSize) descrBadge) fileDescrText
|
||||
pure . L.toList $ L.map (\fd@FileDescr {fileDescrComplete} -> XMsgFileDescr msgId fd fileExpires (if fileDescrComplete then descrBadge else Nothing)) parts
|
||||
_ -> pure []
|
||||
let fileDescrVMs = map (VMUnsigned . ChatMessage senderVRange Nothing) fileDescrEvents
|
||||
pure $ map ((,) fwd) (contentVM : fileDescrVMs)
|
||||
@@ -1486,11 +1542,16 @@ memberShortenedName GroupMember {memberProfile = LocalProfile {displayName}}
|
||||
| T.length displayName <= 16 = displayName
|
||||
| otherwise = T.take 16 displayName `T.snoc` '…'
|
||||
|
||||
splitFileDescr :: Int -> RcvFileDescrText -> NonEmpty FileDescr
|
||||
splitFileDescr partSize rfdText = splitParts 1 rfdText
|
||||
-- the description proof travels on the last part, so that part leaves room for it
|
||||
badgeDescrPartSize :: Int
|
||||
badgeDescrPartSize = 13500
|
||||
|
||||
splitFileDescr :: Int -> Int -> RcvFileDescrText -> NonEmpty FileDescr
|
||||
splitFileDescr partSize lastSize rfdText = splitParts 1 rfdText
|
||||
where
|
||||
splitParts partNo remText =
|
||||
let (part, rest) = T.splitAt partSize remText
|
||||
let n = T.length remText
|
||||
(part, rest) = T.splitAt (if n <= lastSize then n else if n <= partSize then lastSize else partSize) remText
|
||||
complete = T.null rest
|
||||
fileDescr = FileDescr {fileDescrText = part, fileDescrPartNo = partNo, fileDescrComplete = complete}
|
||||
in if complete
|
||||
@@ -1593,7 +1654,7 @@ groupLinkData gInfo@GroupInfo {groupProfile, groupSummary = GroupSummary {public
|
||||
publicGroupData_ = PublicGroupData <$> publicMemberCount
|
||||
userData = encodeShortLinkData $ GroupShortLinkData {groupProfile, publicGroupData = publicGroupData_}
|
||||
owners = case groupKeys of
|
||||
Just GroupKeys {groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} ->
|
||||
Just GroupKeys {publicGroupKeys = Just PublicGroupKeys {groupRootKey = GRKPrivate rootPrivKey}, memberPrivKey} ->
|
||||
let ownerId = unMemberId memberId
|
||||
ownerKey = C.publicKey memberPrivKey
|
||||
authOwnerSig = C.sign' rootPrivKey (ownerId <> C.encodePubKey ownerKey)
|
||||
@@ -2248,27 +2309,111 @@ createSndMessages idsEvents = do
|
||||
encodeChatMessage maxEncodedMsgLength ChatMessage {chatVRange = vr, msgId = Just sharedMsgId, chatMsgEvent = evnt}
|
||||
|
||||
groupMsgSigning :: Bool -> GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning
|
||||
groupMsgSigning sign gInfo@GroupInfo {membership = GroupMember {memberId}, groupKeys = Just GroupKeys {publicGroupId, memberPrivKey}} evt
|
||||
| useRelays' gInfo && shouldSign =
|
||||
Just $ MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey
|
||||
groupMsgSigning sign GroupInfo {membership = GroupMember {memberId}, groupKeys} evt = case groupKeys of
|
||||
Just gks@GroupKeys {memberPrivKey} | shouldSign -> Just $ MsgSigning CBGroup bindingData KRMember memberPrivKey
|
||||
where
|
||||
tag = toCMEventTag evt
|
||||
shouldSign = requiresSignature tag || (sign && signableContent tag)
|
||||
bindingData = groupBindingData (Just gks) memberId (C.publicKey memberPrivKey)
|
||||
_ -> Nothing
|
||||
|
||||
groupBindingData :: Maybe GroupKeys -> MemberId -> C.PublicKeyEd25519 -> ByteString
|
||||
groupBindingData gks memberId memberKey = case gks >>= publicGroupKeys of
|
||||
Just PublicGroupKeys {publicGroupId} -> smpEncode (publicGroupId, memberId)
|
||||
Nothing -> smpEncode (memberId, memberKey)
|
||||
|
||||
type HistoryFile = (FileInvitation, RcvFileDescrText, Maybe UTCTime, Maybe BadgeProof)
|
||||
|
||||
directChatBinding :: Contact -> CM (Maybe ByteString)
|
||||
directChatBinding ct =
|
||||
forM (contactConn ct) $ \conn ->
|
||||
encodeChatBinding CBDirect <$> withAgent (`getConnectionRatchetAdHash` aConnId conn)
|
||||
|
||||
rcvGroupChatBinding :: GroupInfo -> Maybe GroupMember -> ShowGroupAsSender -> Maybe BadgeProof -> Maybe ByteString
|
||||
rcvGroupChatBinding GroupInfo {groupKeys} m_ asGroup badge_ =
|
||||
case (groupKeys >>= publicGroupKeys, asGroup, m_) of
|
||||
(Just PublicGroupKeys {publicGroupId}, True, _) ->
|
||||
Just $ encodeChatBinding CBChannel $ smpEncode publicGroupId
|
||||
(Just PublicGroupKeys {publicGroupId}, False, Just GroupMember {memberId}) ->
|
||||
Just $ encodeChatBinding CBGroup $ smpEncode (publicGroupId, memberId)
|
||||
(Nothing, False, Just GroupMember {memberId, memberPubKey}) ->
|
||||
(\k -> encodeChatBinding CBGroup $ smpEncode (memberId, k)) <$> (memberPubKey <|> proofMemberKey memberId badge_)
|
||||
_ -> Nothing
|
||||
|
||||
proofMemberKey :: MemberId -> Maybe BadgeProof -> Maybe C.PublicKeyEd25519
|
||||
proofMemberKey memberId badge_ = do
|
||||
BadgeProof _ (BBSPresHeader phBytes) _ _ <- badge_
|
||||
binding <- headerChatBinding =<< eitherToMaybe (strDecode phBytes)
|
||||
d <- B.stripPrefix (smpEncode CBGroup) binding
|
||||
(mId, k) <- eitherToMaybe (smpDecode d :: Either String (MemberId, C.PublicKeyEd25519))
|
||||
if mId == memberId then Just k else Nothing
|
||||
where
|
||||
tag = toCMEventTag evt
|
||||
shouldSign = requiresSignature tag || (sign && signableContent tag)
|
||||
groupMsgSigning _ _ _ = Nothing
|
||||
headerChatBinding = \case
|
||||
PHFileInv {chatBinding} -> Just chatBinding
|
||||
PHFileDescr {chatBinding} -> Just chatBinding
|
||||
_ -> Nothing
|
||||
|
||||
badgeProofStatus :: Maybe ProofPresHeader -> BadgeProof -> CM BadgeStatus
|
||||
badgeProofStatus expected_ badge@BadgeProof {presHeader = BBSPresHeader phBytes, badgeInfo} =
|
||||
case expected_ of
|
||||
Just expected | phBytes == strEncode expected -> do
|
||||
keys <- asks $ badgePublicKeys . config
|
||||
verified <- liftIO $ verifyBadge keys badge
|
||||
now <- liftIO getCurrentTime
|
||||
pure $ mkBadgeStatus now verified badgeInfo
|
||||
_ -> pure BSFailed
|
||||
|
||||
rcvDirectFileProhibited :: Contact -> FileInvitation -> CM (Maybe FileProhibited)
|
||||
rcvDirectFileProhibited ct fInv@FileInvitation {fileBadge} = do
|
||||
binding_ <- if isJust fileBadge then directChatBinding ct else pure Nothing
|
||||
rcvFileProhibited binding_ fInv
|
||||
|
||||
rcvGroupFileProhibited :: GroupInfo -> Maybe GroupMember -> ShowGroupAsSender -> FileInvitation -> CM (Maybe FileProhibited)
|
||||
rcvGroupFileProhibited gInfo m_ asGroup fInv@FileInvitation {fileBadge} =
|
||||
rcvFileProhibited (rcvGroupChatBinding gInfo m_ asGroup fileBadge) fInv
|
||||
|
||||
rcvFileProhibited :: Maybe ByteString -> FileInvitation -> CM (Maybe FileProhibited)
|
||||
rcvFileProhibited binding_ FileInvitation {fileSize, fileBadge} = do
|
||||
lims <- asks $ fileSizeLimits . config
|
||||
if fileSize <= noBadge lims
|
||||
then pure Nothing
|
||||
else case fileBadge of
|
||||
Nothing -> pure $ Just FileProhibited {maxSize = noBadge lims, badgeStatus = Nothing}
|
||||
Just badge -> do
|
||||
st <- badgeProofStatus ((\chatBinding -> PHFileInv {chatBinding, fileSize = fromInteger fileSize}) <$> binding_) badge
|
||||
let maxSize = maxXFTPFileSize lims $ Just $ PeerBadge badge st
|
||||
pure $
|
||||
if fileSize <= maxSize
|
||||
then Nothing
|
||||
else Just FileProhibited {maxSize, badgeStatus = Just st}
|
||||
|
||||
createUserMemberKey :: GroupInfo -> CM GroupInfo
|
||||
createUserMemberKey gInfo@GroupInfo {groupId, membership, groupKeys}
|
||||
| useRelays' gInfo || isJust groupKeys = pure gInfo
|
||||
| otherwise = do
|
||||
(_, memberPrivKey) <- atomically . C.generateKeyPair =<< asks random
|
||||
withStore' $ \db -> setUserMemberKey db groupId (groupMemberId' membership) memberPrivKey
|
||||
pure gInfo {groupKeys = Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey}}
|
||||
|
||||
groupMemberKey :: GroupInfo -> Maybe MemberKey
|
||||
groupMemberKey GroupInfo {groupKeys} = MemberKey . C.publicKey . memberPrivKey <$> groupKeys
|
||||
|
||||
sendGroupMemberMessages :: forall e. MsgEncodingI e => User -> GroupInfo -> Connection -> NonEmpty (ChatMsgEvent e) -> CM ()
|
||||
sendGroupMemberMessages user gInfo@GroupInfo {groupId} conn events = do
|
||||
when (connDisabled conn) $ throwChatError (CEConnectionDisabled conn)
|
||||
let idsEvts = L.map (\evt -> (GroupId groupId, groupMsgSigning False gInfo evt, evt)) events
|
||||
mode = if useRelays' gInfo then BMBinary else BMJson
|
||||
(errs, msgs) <- lift $ partitionEithers . L.toList <$> createSndMessages idsEvts
|
||||
unless (null errs) $ toView $ CEvtChatErrors errs
|
||||
forM_ (L.nonEmpty msgs) $ \msgs' ->
|
||||
batchSendConnMessages mode user conn MsgFlags {notification = True} msgs'
|
||||
batchSendConnMessages gInfo user conn MsgFlags {notification = True} msgs'
|
||||
|
||||
batchSendConnMessages :: BatchMode -> User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption)
|
||||
batchSendConnMessages mode user conn msgFlags msgs =
|
||||
batchSendConnMessages :: GroupInfo -> User -> Connection -> MsgFlags -> NonEmpty SndMessage -> CM ([Either ChatError SndMessage], Maybe PQEncryption)
|
||||
batchSendConnMessages gInfo user conn msgFlags msgs =
|
||||
batchSendConnMessagesB mode user conn msgFlags $ L.map Right msgs
|
||||
where
|
||||
mode
|
||||
| useRelays' gInfo || maxVersion (peerChatVRange conn) >= relayWebCapVersion = BMBinary
|
||||
| otherwise = BMJson
|
||||
|
||||
batchSendConnMessagesB :: BatchMode -> User -> Connection -> MsgFlags -> NonEmpty (Either ChatError SndMessage) -> CM ([Either ChatError SndMessage], Maybe PQEncryption)
|
||||
batchSendConnMessagesB mode _user conn msgFlags msgs_ = do
|
||||
@@ -2326,9 +2471,10 @@ encodeSignedConnInfo signing chatMsgEvent = do
|
||||
encodeXMemberConnInfo :: GroupInfo -> MemberId -> Profile -> CM ByteString
|
||||
encodeXMemberConnInfo GroupInfo {membership = GroupMember {memberId}, groupKeys} relayMemberId profileToSend =
|
||||
case groupKeys of
|
||||
Just GroupKeys {publicGroupId, memberPrivKey} ->
|
||||
Just gks@GroupKeys {memberPrivKey} ->
|
||||
let xMemberEvt = XMember profileToSend memberId (MemberKey $ C.publicKey memberPrivKey) (Just relayMemberId)
|
||||
signing = MsgSigning CBGroup (smpEncode (publicGroupId, memberId)) KRMember memberPrivKey
|
||||
bindingData = groupBindingData (Just gks) memberId (C.publicKey memberPrivKey)
|
||||
signing = MsgSigning CBGroup bindingData KRMember memberPrivKey
|
||||
in encodeSignedConnInfo signing xMemberEvt
|
||||
Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
|
||||
|
||||
@@ -2483,13 +2629,15 @@ sendRelayCapIfNeeded user gInfo = do
|
||||
withStore' $ \db -> updateRelaySentWebDomain db gInfo currentWebDomain
|
||||
|
||||
sendGroupMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> Bool -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
|
||||
sendGroupMessages user gInfo scope asGroup members sign events = do
|
||||
sendGroupMessages user gInfo' scope asGroup members sign events = do
|
||||
gInfo <- createUserMemberKey gInfo'
|
||||
sendGroupProfileUpdate user gInfo scope asGroup members
|
||||
sendGroupMessages_ user gInfo members sign events
|
||||
|
||||
-- per-item signer variant of sendGroupMessages (used for per-item delete signing); preserves the profile-update prelude
|
||||
sendGroupSignedMessages :: MsgEncodingI e => User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (Maybe MsgSigning, ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)
|
||||
sendGroupSignedMessages user gInfo scope asGroup members signedEvents = do
|
||||
sendGroupSignedMessages user gInfo' scope asGroup members signedEvents = do
|
||||
gInfo <- createUserMemberKey gInfo'
|
||||
sendGroupProfileUpdate user gInfo scope asGroup members
|
||||
sendGroupSignedMessages_ gInfo members signedEvents
|
||||
|
||||
@@ -2513,7 +2661,7 @@ sendGroupProfileUpdate user gInfo scope asGroup members =
|
||||
sendProfileUpdate = do
|
||||
-- shouldSendProfileUpdate excludes incognito membership, so the badge is presented
|
||||
profileUpdate <- presentUserBadge user Nothing $ redactedMemberProfile gInfo (membership gInfo) $ fromLocalProfile p
|
||||
void $ sendGroupMessage' user gInfo members $ XInfo profileUpdate
|
||||
void $ sendGroupMessage' user gInfo members $ XInfo profileUpdate (groupMemberKey gInfo)
|
||||
currentTs <- liftIO getCurrentTime
|
||||
withStore' $ \db -> updateUserMemberProfileSentAt db user gInfo currentTs
|
||||
|
||||
@@ -2533,7 +2681,7 @@ sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents
|
||||
recipientMembers' <- liftIO $ shuffleMembers recipientMembers
|
||||
let msgFlags = MsgFlags {notification = any (hasNotification . toCMEventTag) events}
|
||||
(toSend, toPending, forwarded, _, dups) =
|
||||
foldr' (addMember recipientMembers') ([], [], [], S.empty, 0 :: Int) recipientMembers'
|
||||
foldr' (addMember recipientMembers') (([], []), [], [], S.empty, 0 :: Int) recipientMembers'
|
||||
when (dups /= 0) $ logError $ "sendGroupMessages_: " <> tshow dups <> " duplicate members"
|
||||
-- TODO PQ either somehow ensure that group members connections cannot have pqSupport/pqEncryption or pass Off's here
|
||||
-- Deliver to toSend members
|
||||
@@ -2557,26 +2705,30 @@ sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents
|
||||
liftM2 (<>) (shuffle adminMs) (shuffle otherMs)
|
||||
where
|
||||
isAdmin GroupMember {memberRole} = memberRole >= GRAdmin
|
||||
addMember members m acc@(toSend, pending, forwarded, !mIds, !dups) =
|
||||
addMember members m acc@(toSend@(toSendBin, toSendJson), pending, forwarded, !mIds, !dups) =
|
||||
case memberSendAction gInfo events members m of
|
||||
Just a
|
||||
| mId `S.member` mIds -> (toSend, pending, forwarded, mIds, dups + 1)
|
||||
| otherwise -> case a of
|
||||
MSASend conn -> ((m, conn) : toSend, pending, forwarded, mIds', dups)
|
||||
MSASend conn ->
|
||||
let toSend' = case batchMode gInfo m of
|
||||
BMBinary -> ((m, conn) : toSendBin, toSendJson)
|
||||
BMJson -> (toSendBin, (m, conn) : toSendJson)
|
||||
in (toSend', pending, forwarded, mIds', dups)
|
||||
MSAPending -> (toSend, m : pending, forwarded, mIds', dups)
|
||||
MSAForwarded -> (toSend, pending, m : forwarded, mIds', dups)
|
||||
Nothing -> acc
|
||||
where
|
||||
mId = groupMemberId' m
|
||||
mIds' = S.insert mId mIds
|
||||
prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
prepareMsgReqs msgFlags msgs toSend = do
|
||||
let mode = if useRelays' gInfo then BMBinary else BMJson
|
||||
batched_ = batchSndMessagesJSON mode msgs
|
||||
case L.nonEmpty batched_ of
|
||||
Just batched' -> foldMembers (length batched' + length msgs) msgBatchMBR batched' toSend
|
||||
Nothing -> ([], [])
|
||||
prepareMsgReqs :: MsgFlags -> NonEmpty (Either ChatError SndMessage) -> ([(GroupMember, Connection)], [(GroupMember, Connection)]) -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
prepareMsgReqs msgFlags msgs (toSendBin, toSendJson) =
|
||||
batchReqs 1 BMBinary toSendBin <> batchReqs 2 BMJson toSendJson
|
||||
where
|
||||
batchReqs _ _ [] = ([], [])
|
||||
batchReqs n mode toSend' = case L.nonEmpty (batchSndMessagesJSON mode msgs) of
|
||||
Just batched -> foldMembers (n * (length batched + length msgs)) msgBatchMBR batched toSend'
|
||||
Nothing -> ([], [])
|
||||
foldMembers :: forall a. Int -> (Maybe Int -> Int -> a -> (ValueOrRef MsgBody, [MessageId])) -> NonEmpty (Either ChatError a) -> [(GroupMember, Connection)] -> ([GroupMemberId], [Either ChatError ChatMsgReq])
|
||||
foldMembers lastRef mkMb mbs mems = snd $ foldr' foldMsgBodies (lastMemIdx_, ([], [])) mems
|
||||
where
|
||||
@@ -2609,6 +2761,11 @@ sendGroupSignedMessages_ gInfo@GroupInfo {groupId} recipientMembers signedEvents
|
||||
createPendingMsg db (groupMemberId, msgId) =
|
||||
createPendingGroupMessage db groupMemberId msgId $> Right ()
|
||||
|
||||
batchMode :: GroupInfo -> GroupMember -> BatchMode
|
||||
batchMode gInfo m
|
||||
| useRelays' gInfo || m `supportsVersion` relayWebCapVersion = BMBinary
|
||||
| otherwise = BMJson
|
||||
|
||||
data MemberSendAction = MSASend Connection | MSAPending | MSAForwarded
|
||||
|
||||
memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction
|
||||
@@ -2682,10 +2839,9 @@ sendFwdMemberMessage member fwd verifiedMsg =
|
||||
-- TODO ensure order - pending messages interleave with user input messages
|
||||
sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM ()
|
||||
sendPendingGroupMessages user gInfo GroupMember {groupMemberId} conn = do
|
||||
let mode = if useRelays' gInfo then BMBinary else BMJson
|
||||
msgs <- withStore' $ \db -> getPendingGroupMessages db groupMemberId
|
||||
forM_ (L.nonEmpty msgs) $ \msgs' -> do
|
||||
void $ batchSendConnMessages mode user conn MsgFlags {notification = True} msgs'
|
||||
void $ batchSendConnMessages gInfo user conn MsgFlags {notification = True} msgs'
|
||||
lift . void . withStoreBatch' $ \db -> L.map (\SndMessage {msgId} -> deletePendingGroupMessage db groupMemberId msgId) msgs'
|
||||
|
||||
saveDirectRcvMSG :: forall e. MsgEncodingI e => Connection -> MsgMeta -> ChatMessage e -> CM (Connection, RcvMessage)
|
||||
@@ -2890,10 +3046,19 @@ joinAgentConnectionAsync :: CommandId -> Bool -> ConnId -> Bool -> ConnectionReq
|
||||
joinAgentConnectionAsync cmdId updateConn connId enableNtfs cReqUri cInfo subMode =
|
||||
withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) updateConn connId enableNtfs cReqUri cInfo PQSupportOff subMode
|
||||
|
||||
allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM ()
|
||||
allowAgentConnectionAsync user conn@Connection {connId, pqSupport} confId msg = do
|
||||
allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> Maybe GroupInfo -> ChatMsgEvent e -> CM ()
|
||||
allowAgentConnectionAsync user conn@Connection {pqSupport} confId gInfo_ msg = do
|
||||
let signing_ = case gInfo_ of
|
||||
Just gInfo | useRelays' gInfo || maxVersion (peerChatVRange conn) >= relayWebCapVersion -> groupMsgSigning False gInfo msg
|
||||
_ -> Nothing
|
||||
dm <- case signing_ of
|
||||
Just signing -> encodeSignedConnInfo signing msg
|
||||
Nothing -> encodeConnInfoPQ pqSupport msg
|
||||
allowAgentConnectionInfo user conn confId dm
|
||||
|
||||
allowAgentConnectionInfo :: User -> Connection -> ConfirmationId -> ByteString -> CM ()
|
||||
allowAgentConnectionInfo user conn@Connection {connId} confId dm = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn
|
||||
dm <- encodeConnInfoPQ pqSupport msg
|
||||
withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm
|
||||
withStore' $ \db -> updateConnectionStatus db conn ConnAccepted
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ 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.Badges (BadgeProof, BadgeProofKind (..), BadgeStatus (..), FileSizeLimits (..), ProofPresHeader (..))
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller
|
||||
import Simplex.Chat.Delivery
|
||||
@@ -76,7 +77,7 @@ import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Chat.Types.Shared
|
||||
import Simplex.FileTransfer.Description (ValidFileDescription)
|
||||
import qualified Simplex.FileTransfer.Description as FD
|
||||
import Simplex.FileTransfer.Protocol (FilePartyI, GrantedStorageTime (..))
|
||||
import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI, GrantedStorageTime (..))
|
||||
import qualified Simplex.FileTransfer.Transport as XFTP
|
||||
import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId)
|
||||
import Simplex.Messaging.Agent
|
||||
@@ -112,11 +113,11 @@ import qualified Data.Aeson as J
|
||||
smallGroupsRcptsMemLimit :: Int
|
||||
smallGroupsRcptsMemLimit = 20
|
||||
|
||||
-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) <> signedBody under the given key.
|
||||
-- Verifies member signatures over CBGroup <> (publicGroupId, memberId) or (memberId, pubKey) <> 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)
|
||||
verifyGroupSig :: C.PublicKeyEd25519 -> Maybe GroupKeys -> MemberId -> NonEmpty MsgSignature -> ByteString -> Bool
|
||||
verifyGroupSig key gks memberId signatures signedBody =
|
||||
let prefix = encodeChatBinding CBGroup $ groupBindingData gks memberId key
|
||||
in all (\case (MsgSignature KRMember sig) -> C.verify (C.APublicVerifyKey C.SEd25519 key) sig (prefix <> signedBody)) signatures
|
||||
|
||||
processAgentMessage :: ACorrId -> ConnId -> AEvent 'AEConn -> CM ()
|
||||
@@ -134,10 +135,33 @@ processAgentMessage corrId connId msg = do
|
||||
lockEntity <- critical connId (withStore (`getChatLockEntity` AgentConnId connId))
|
||||
withEntityLock "processAgentMessage" lockEntity $ do
|
||||
cxt <- chatStoreCxt
|
||||
-- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here
|
||||
critical connId (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case
|
||||
Just user -> processAgentMessageConn cxt user corrId connId msg `catchAllErrors` eToView
|
||||
-- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert,
|
||||
-- as in this case no need to ACK message - we can't process messages for this connection anyway.
|
||||
critical connId (withStore $ getUserEntity cxt) >>= \case
|
||||
Just (user, entity) -> processAgentMessageConn cxt user entity corrId connId msg `catchAllErrors` eToView
|
||||
_ -> throwChatError $ CENoConnectionUser (AgentConnId connId)
|
||||
where
|
||||
getUserEntity :: StoreCxt -> DB.Connection -> ExceptT StoreError IO (Maybe (User, ConnectionEntity))
|
||||
getUserEntity cxt db =
|
||||
liftIO (getUserByAConnId db $ AgentConnId connId)
|
||||
>>= mapM (\user -> (user,) <$> (getConnectionEntity db cxt user (AgentConnId connId) >>= liftIO . updateConnStatus db))
|
||||
|
||||
updateConnStatus :: DB.Connection -> ConnectionEntity -> IO ConnectionEntity
|
||||
updateConnStatus db acEntity = case agentMsgConnStatus (entityConnection acEntity) msg of
|
||||
Just connStatus -> do
|
||||
let conn = (entityConnection acEntity) {connStatus}
|
||||
updateConnectionStatus db conn connStatus
|
||||
pure $ updateEntityConnStatus acEntity connStatus
|
||||
Nothing -> pure acEntity
|
||||
|
||||
agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus
|
||||
agentMsgConnStatus Connection {connStatus = cs} = \case
|
||||
JOINED True -> Just ConnSndReady
|
||||
CONF {} -> Just ConnRequested
|
||||
INFO {} -> Just ConnSndReady
|
||||
CON _ -> Just ConnReady
|
||||
ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err)
|
||||
_ -> Nothing
|
||||
|
||||
-- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps.
|
||||
-- SEDBBusyError will only be thrown on IO exceptions or SQLError during DB queries,
|
||||
@@ -224,7 +248,7 @@ processAgentMsgSndFile _corrId aFileId msg = do
|
||||
-- we have 1 chunk - use it as URI whether it is redirect or not
|
||||
ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor
|
||||
toView $ CEvtSndStandaloneFileComplete user ft' $ map (decodeLatin1 . strEncode . FD.fileDescriptionURI) rfds'
|
||||
Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted}}) ->
|
||||
Just (AChatItem _ d cInfo _ci@ChatItem {meta = CIMeta {itemSharedMsgId = msgId_, itemDeleted, showGroupAsSender}}) ->
|
||||
case (msgId_, itemDeleted) of
|
||||
(Just sharedMsgId, Nothing) -> do
|
||||
when (length rfds < length sfts) $ throwChatError $ CEInternalError "not enough XFTP file descriptions to send"
|
||||
@@ -232,9 +256,16 @@ processAgentMsgSndFile _corrId aFileId msg = do
|
||||
toView $ CEvtSndFileProgressXFTP user ci ft 1 1
|
||||
case (rfds, sfts, d, cInfo) of
|
||||
(rfd : extraRFDs, sft : _, SMDSnd, DirectChat ct) -> do
|
||||
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
||||
conn@Connection {connId} <- liftEither $ contactSendConn_ ct
|
||||
sendFileDescriptions (ConnectionId connId) ((conn, sft, fileDescrText rfd) :| []) sharedMsgId fileExpires >>= \case
|
||||
let FileTransferMeta {fileSize} = ft
|
||||
binding_ <- ifM ((not (contactConnIncognito ct) &&) <$> fileNeedsBadge fileSize) (directChatBinding ct) (pure Nothing)
|
||||
descrBadge <- pure binding_ $>>= \chatBinding ->
|
||||
let FD.ValidFileDescription fd = sndDescr
|
||||
in sndBadgeProof user PHFileDescr {chatBinding, fileSize = fromInteger fileSize, descrHash = FD.sharedDescriptionHash fd, fileExpires}
|
||||
withStore' $ \db -> do
|
||||
createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
||||
forM_ descrBadge $ createFileBadgeProof db fileId BPKDescription
|
||||
sendFileDescriptions (ConnectionId connId) ((conn, sft, fileDescrText rfd) :| []) sharedMsgId fileExpires descrBadge >>= \case
|
||||
Just rs -> case L.last rs of
|
||||
Right ([msgDeliveryId], _) ->
|
||||
withStore' $ \db -> updateSndFTDeliveryXFTP db sft msgDeliveryId
|
||||
@@ -247,9 +278,17 @@ processAgentMsgSndFile _corrId aFileId msg = do
|
||||
ms <- getRecipients
|
||||
let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms)
|
||||
extraRFDs = drop (length rfdsMemberFTs) rfds
|
||||
withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
||||
FileTransferMeta {fileSize} = ft
|
||||
needsBadge <- fileNeedsBadge fileSize
|
||||
let binding_ = if needsBadge && not (incognitoMembership g) then sndGroupChatBinding g showGroupAsSender else Nothing
|
||||
descrBadge <- pure binding_ $>>= \chatBinding ->
|
||||
let FD.ValidFileDescription fd = sndDescr
|
||||
in sndBadgeProof user PHFileDescr {chatBinding, fileSize = fromInteger fileSize, descrHash = FD.sharedDescriptionHash fd, fileExpires}
|
||||
withStore' $ \db -> do
|
||||
createExtraSndFTDescrs db user fileId (map fileDescrText extraRFDs)
|
||||
forM_ descrBadge $ createFileBadgeProof db fileId BPKDescription
|
||||
forM_ (L.nonEmpty rfdsMemberFTs) $ \rfdsMemberFTs' ->
|
||||
sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId fileExpires
|
||||
sendFileDescriptions (GroupId groupId) rfdsMemberFTs' sharedMsgId fileExpires descrBadge
|
||||
ci' <- withStore $ \db -> do
|
||||
liftIO $ updateCIFileStatus db user fileId CIFSSndComplete
|
||||
getChatItemByFileId db cxt user fileId
|
||||
@@ -278,8 +317,8 @@ processAgentMsgSndFile _corrId aFileId msg = do
|
||||
where
|
||||
fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text
|
||||
fileDescrText = safeDecodeUtf8 . strEncode
|
||||
sendFileDescriptions :: ConnOrGroupId -> NonEmpty (Connection, SndFileTransfer, RcvFileDescrText) -> SharedMsgId -> Maybe UTCTime -> CM (Maybe (NonEmpty (Either ChatError ([Int64], PQEncryption))))
|
||||
sendFileDescriptions connOrGroupId connsTransfersDescrs sharedMsgId fileExpires = do
|
||||
sendFileDescriptions :: ConnOrGroupId -> NonEmpty (Connection, SndFileTransfer, RcvFileDescrText) -> SharedMsgId -> Maybe UTCTime -> Maybe BadgeProof -> CM (Maybe (NonEmpty (Either ChatError ([Int64], PQEncryption))))
|
||||
sendFileDescriptions connOrGroupId connsTransfersDescrs sharedMsgId fileExpires descrBadge = do
|
||||
lift . void . withStoreBatch' $ \db -> L.map (\(_, sft, rfdText) -> updateSndFTDescrXFTP db user sft rfdText) connsTransfersDescrs
|
||||
partSize <- asks $ xftpDescrPartSize . config
|
||||
let connsIdsEvts = connDescrEvents partSize
|
||||
@@ -295,7 +334,7 @@ processAgentMsgSndFile _corrId aFileId msg = do
|
||||
where
|
||||
splitText :: (Connection, SndFileTransfer, RcvFileDescrText) -> [(Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json))]
|
||||
splitText (conn, _, rfdText) =
|
||||
map (\fileDescr -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr, fileExpires}))) (L.toList $ splitFileDescr partSize rfdText)
|
||||
map (\fileDescr@FileDescr {fileDescrComplete} -> (conn, (connOrGroupId, Nothing, XMsgFileDescr {msgId = sharedMsgId, fileDescr, fileExpires, fileBadge = if fileDescrComplete then descrBadge else Nothing}))) (L.toList $ splitFileDescr partSize (maybe partSize (const badgeDescrPartSize) descrBadge) rfdText)
|
||||
toMsgReq :: (Connection, (ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent 'Json)) -> SndMessage -> ChatMsgReq
|
||||
toMsgReq (conn, _) SndMessage {msgId, msgBody} =
|
||||
(conn, MsgFlags {notification = hasNotification XMsgFileDescr_}, (vrValue msgBody, [msgId]))
|
||||
@@ -392,11 +431,8 @@ processAgentMsgRcvFile _corrId aFileId msg = do
|
||||
|
||||
type ShouldDeleteGroupConns = Bool
|
||||
|
||||
processAgentMessageConn :: StoreCxt -> User -> ACorrId -> ConnId -> AEvent 'AEConn -> CM ()
|
||||
processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = do
|
||||
-- Missing connection/entity errors here will be sent to the view but not shown as CRITICAL alert,
|
||||
-- as in this case no need to ACK message - we can't process messages for this connection anyway.
|
||||
entity <- critical agentConnId $ withStore (\db -> getConnectionEntity db cxt user $ AgentConnId agentConnId) >>= updateConnStatus
|
||||
processAgentMessageConn :: StoreCxt -> User -> ConnectionEntity -> ACorrId -> ConnId -> AEvent 'AEConn -> CM ()
|
||||
processAgentMessageConn cxt user@User {userId} entity corrId agentConnId agentMessage =
|
||||
case agentMessage of
|
||||
END -> case entity of
|
||||
RcvDirectMsgConnection _ (Just ct) -> toView $ CEvtContactAnotherClient user ct
|
||||
@@ -410,23 +446,6 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
UserContactConnection conn uc ->
|
||||
processContactConnMessage agentMessage entity conn uc
|
||||
where
|
||||
updateConnStatus :: ConnectionEntity -> CM ConnectionEntity
|
||||
updateConnStatus acEntity = case agentMsgConnStatus (entityConnection acEntity) agentMessage of
|
||||
Just connStatus -> do
|
||||
let conn = (entityConnection acEntity) {connStatus}
|
||||
withStore' $ \db -> updateConnectionStatus db conn connStatus
|
||||
pure $ updateEntityConnStatus acEntity connStatus
|
||||
Nothing -> pure acEntity
|
||||
|
||||
agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus
|
||||
agentMsgConnStatus Connection {connStatus = cs} = \case
|
||||
JOINED True -> Just ConnSndReady
|
||||
CONF {} -> Just ConnRequested
|
||||
INFO {} -> Just ConnSndReady
|
||||
CON _ -> Just ConnReady
|
||||
ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err)
|
||||
_ -> Nothing
|
||||
|
||||
processCONFpqSupport :: Connection -> PQSupport -> CM Connection
|
||||
processCONFpqSupport conn@Connection {connId, pqSupport = pq} pq'
|
||||
| pq == PQSupportOn && pq' == PQSupportOff = do
|
||||
@@ -481,7 +500,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
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
|
||||
allowAgentConnectionAsync user conn'' confId gInfo_ $ XInfo profileToSend (groupMemberKey =<< gInfo_)
|
||||
INFO pqSupport connInfo -> do
|
||||
processINFOpqSupport conn pqSupport
|
||||
void $ saveConnInfo conn connInfo
|
||||
@@ -491,7 +510,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
withAckMessage' "new contact msg" agentConnId meta $ pure ()
|
||||
SENT msgId _proxy -> do
|
||||
void $ continueSending connEntity conn
|
||||
sentMsgDeliveryEvent conn msgId
|
||||
withStore' $ \db -> sentMsgDeliveryEvent db conn msgId
|
||||
OK ->
|
||||
-- [async agent commands] continuation on receiving OK
|
||||
when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure ()
|
||||
@@ -550,7 +569,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
let ct'' = ct' {activeConn = Just conn''} :: Contact
|
||||
case event of
|
||||
XMsgNew mc -> newContentMessage ct'' mc msg msgMeta
|
||||
XMsgFileDescr sharedMsgId fileDescr fileExpires -> messageFileDescription ct'' sharedMsgId fileDescr fileExpires
|
||||
XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> messageFileDescription ct'' sharedMsgId fileDescr fileExpires fileBadge
|
||||
XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live
|
||||
XMsgDel sharedMsgId _ _ _ -> messageDelete ct'' sharedMsgId msg msgMeta
|
||||
XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta
|
||||
@@ -558,7 +577,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
XFile fInv -> processFileInvitation' ct'' fInv msg msgMeta
|
||||
XFileCancel sharedMsgId -> xFileCancel ct'' sharedMsgId
|
||||
XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct'' sharedMsgId fileConnReq_ fName
|
||||
XInfo p -> xInfo ct'' p
|
||||
XInfo p _ -> xInfo ct'' p
|
||||
XDirectDel -> xDirectDel ct'' msg msgMeta
|
||||
XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta
|
||||
XInfoProbe probe -> xInfoProbe (COMContact ct'') probe
|
||||
@@ -591,24 +610,25 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
-- TODO check member ID
|
||||
-- TODO update member profile
|
||||
-- [async agent commands] no continuation needed, but command should be asynchronous for stability
|
||||
allowAgentConnectionAsync user conn'' confId XOk
|
||||
XInfo profile -> do
|
||||
allowAgentConnectionAsync user conn'' confId Nothing XOk
|
||||
XInfo profile _ -> do
|
||||
ct' <- processContactProfileUpdate ct profile False `catchAllErrors` const (pure ct)
|
||||
-- [incognito] send incognito profile
|
||||
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore $ \db -> getProfileById db userId profileId
|
||||
p <- presentUserBadge user incognitoProfile $ userProfileDirect user (fromLocalProfile <$> incognitoProfile) (Just ct') True
|
||||
allowAgentConnectionAsync user conn'' confId $ XInfo p
|
||||
allowAgentConnectionAsync user conn'' confId Nothing $ XInfo p Nothing
|
||||
void $ withStore' $ \db -> resetMemberContactFields db ct'
|
||||
XGrpLinkInv glInv -> do
|
||||
-- XGrpLinkInv here means we are connecting via business contact card, so we replace contact with group
|
||||
memberKeys <- atomically . C.generateKeyPair =<< asks random
|
||||
(gInfo, host) <- withStore $ \db -> do
|
||||
liftIO $ deleteContactCardKeepConn db connId ct
|
||||
createGroupInvitedViaLink db cxt user conn'' glInv
|
||||
createGroupInvitedViaLink db cxt user conn'' memberKeys glInv
|
||||
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)
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile)
|
||||
allowAgentConnectionAsync user conn'' confId $ XInfo profileToSend
|
||||
allowAgentConnectionAsync user conn'' confId (Just gInfo) $ XInfo profileToSend (groupMemberKey gInfo)
|
||||
toView $ CEvtBusinessLinkConnecting user gInfo host ct
|
||||
_ -> messageError "CONF for existing contact must have x.grp.mem.info or x.info"
|
||||
INFO pqSupport connInfo -> do
|
||||
@@ -620,7 +640,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
-- TODO check member ID
|
||||
-- TODO update member profile
|
||||
pure ()
|
||||
XInfo profile -> do
|
||||
XInfo profile _ -> do
|
||||
let prepared = isJust (preparedContact ct) || isJust (contactRequestId' ct)
|
||||
void $ processContactProfileUpdate ct profile prepared
|
||||
XOk -> pure ()
|
||||
@@ -651,11 +671,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
withStore' $ \db -> resetContactConnInitiated db user conn'
|
||||
SENT msgId proxy -> do
|
||||
void $ continueSending connEntity conn
|
||||
sentMsgDeliveryEvent conn msgId
|
||||
checkSndInlineFTComplete conn msgId
|
||||
cis <- withStore $ \db -> do
|
||||
(fileEvent_, cis) <- withStore $ \db -> do
|
||||
liftIO $ sentMsgDeliveryEvent db conn msgId
|
||||
fileEvent_ <- checkSndInlineFTComplete db conn msgId
|
||||
cis <- updateDirectItemsStatus' db ct conn msgId (CISSndSent SSPComplete)
|
||||
liftIO $ forM cis $ \ci -> setDirectSndChatItemViaProxy db user ct ci (isJust proxy)
|
||||
(fileEvent_,) <$> liftIO (forM cis $ \ci -> setDirectSndChatItemViaProxy db user ct ci (isJust proxy))
|
||||
mapM_ toView fileEvent_
|
||||
let acis = map ctItem cis
|
||||
unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis
|
||||
where
|
||||
@@ -754,11 +775,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
case memberCategory m of
|
||||
GCInviteeMember ->
|
||||
case chatMsgEvent of
|
||||
XGrpAcpt memId
|
||||
XGrpAcpt memId mKey
|
||||
| sameMemberId memId m -> do
|
||||
withStore $ \db -> liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
|
||||
forM_ mKey $ \(MemberKey k) -> withStore' $ \db -> setMemberPubKey db (groupMemberId' m) k
|
||||
-- [async agent commands] no continuation needed, but command should be asynchronous for stability
|
||||
allowAgentConnectionAsync user conn' confId XOk
|
||||
allowAgentConnectionAsync user conn' confId (Just gInfo) XOk
|
||||
| otherwise -> messageError "x.grp.acpt: memberId is different from expected"
|
||||
XGrpRelayAcpt relayLink relayCap
|
||||
| memberRole' membership == GROwner && isRelay m -> do
|
||||
@@ -778,7 +800,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
liftIO $ updateGroupMemberStatus db userId m GSMemLeft
|
||||
pure (relay', m {memberStatus = GSMemLeft})
|
||||
-- complete the contact handshake so the relay receives INFO and cleans up its transient bookkeeping
|
||||
allowAgentConnectionAsync user conn' confId XOk
|
||||
allowAgentConnectionAsync user conn' confId (Just gInfo) XOk
|
||||
toView $ CEvtGroupRelayUpdated user gInfo m' relay'
|
||||
toViewTE $ TERelayRejected user gInfo reason
|
||||
| otherwise -> messageError "x.grp.relay.reject: only owner should receive relay rejection"
|
||||
@@ -790,11 +812,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
pgId = fmap (\PublicGroupProfile {publicGroupId} -> publicGroupId),
|
||||
useRelays' gInfo == isJust rcvPG && pgId rcvPG == pgId curPG -> do
|
||||
-- XGrpLinkInv here means we are connecting via prepared group, and we have to update user and host member records
|
||||
(gInfo', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv
|
||||
(gInfo'', m') <- withStore $ \db -> updatePreparedUserAndHostMembersInvited db cxt user gInfo m glInv
|
||||
gInfo' <- createUserMemberKey gInfo''
|
||||
-- [incognito] send saved profile
|
||||
incognitoProfile <- forM customUserProfileId $ \pId -> withStore (\db -> getProfileById db userId pId)
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromLocalProfile <$> incognitoProfile)
|
||||
allowAgentConnectionAsync user conn' confId $ XInfo profileToSend
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo' (fromLocalProfile <$> incognitoProfile)
|
||||
allowAgentConnectionAsync user conn' confId (Just gInfo') $ XInfo profileToSend (groupMemberKey gInfo')
|
||||
toView $ CEvtGroupLinkConnecting user gInfo' m'
|
||||
| otherwise -> messageError "x.grp.link.inv: publicGroupId mismatch"
|
||||
XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do
|
||||
@@ -810,11 +833,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
membershipProfile <- presentUserBadge user (incognitoMembershipProfile gInfo) $ redactedMemberProfile gInfo membership $ 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
|
||||
allowAgentConnectionAsync user conn' confId (Just gInfo) $ XGrpMemInfo membershipMemId membershipProfile
|
||||
| otherwise -> messageError "x.grp.mem.info: memberId is different from expected"
|
||||
_ -> messageError "CONF from member must have x.grp.mem.info"
|
||||
INFO _pqSupport connInfo -> do
|
||||
ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo
|
||||
(signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo
|
||||
_conn' <- updatePeerChatVRange conn chatVRange
|
||||
case chatMsgEvent of
|
||||
XGrpMemInfo memId _memProfile
|
||||
@@ -823,11 +846,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
pure ()
|
||||
| otherwise -> messageError "x.grp.mem.info: memberId is different from expected"
|
||||
-- sent when connecting via group link
|
||||
XInfo _ ->
|
||||
XInfo _ mKey
|
||||
-- TODO Keep rejected member to allow them to appeal against rejection.
|
||||
when (memberStatus m == GSMemRejected) $ do
|
||||
deleteMemberConnection' m True
|
||||
withStore' $ \db -> deleteGroupMember db user m
|
||||
| memberStatus m == GSMemRejected -> do
|
||||
deleteMemberConnection' m True
|
||||
withStore' $ \db -> deleteGroupMember db user m
|
||||
| otherwise -> mapM_ (storeMemberKey gInfo m signedMsg_) mKey
|
||||
XOk ->
|
||||
-- transient relay-reject row cleanup after the rejection handshake completes
|
||||
when (memberCategory m == GCHostMember && not (relayServesGroup gInfo)) $ do
|
||||
@@ -913,7 +937,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
_ -> pure ()
|
||||
toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus}
|
||||
let Connection {viaUserContactLink} = conn
|
||||
when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo''
|
||||
when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' m'
|
||||
if useRelays' gInfo''
|
||||
then do
|
||||
introduceInChannel cxt user gInfo'' m'
|
||||
@@ -931,10 +955,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
_ -> False
|
||||
when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m'
|
||||
where
|
||||
sendXGrpLinkMem gInfo'' = do
|
||||
sendXGrpLinkMem gInfo''' m' = do
|
||||
gInfo'' <- createUserMemberKey gInfo'''
|
||||
let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo''
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile)
|
||||
void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId
|
||||
profileToSend <- presentUserBadge user incognitoProfile $ userProfileInGroup user gInfo'' (fromIncognitoProfile <$> incognitoProfile)
|
||||
sendGroupMemberMessages user gInfo'' conn [XGrpLinkMem profileToSend (groupMemberKey gInfo'')]
|
||||
_ -> do
|
||||
unless (memberPending m) $ withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected
|
||||
notifyMemberConnected gInfo m Nothing
|
||||
@@ -1035,7 +1060,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
where
|
||||
MsgContainer {scope, asGroup} = mc
|
||||
-- file description is always allowed, to allow sending files to support scope
|
||||
XMsgFileDescr sharedMsgId fileDescr fileExpires -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr fileExpires
|
||||
XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr fileExpires fileBadge
|
||||
XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ ->
|
||||
checkSendAsGroup asGroup_ $
|
||||
memberCanSend (Just m'') msgScope $
|
||||
@@ -1047,8 +1072,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs
|
||||
XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId
|
||||
XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName
|
||||
XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p msg brokerTs
|
||||
XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p
|
||||
XInfo p mKey -> fmap ctx <$> xInfoMember gInfo' m'' p mKey msg brokerTs
|
||||
XGrpLinkMem p mKey -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p mKey msg
|
||||
XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs
|
||||
XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl
|
||||
XGrpRelayCap relayCap
|
||||
@@ -1138,9 +1163,12 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
groupMsgReceived gInfo m conn msgMeta msgRcpt
|
||||
SENT msgId proxy -> do
|
||||
continued <- continueSending connEntity conn
|
||||
sentMsgDeliveryEvent conn msgId
|
||||
checkSndInlineFTComplete conn msgId
|
||||
updateGroupItemsStatus gInfo m conn msgId GSSSent (Just $ isJust proxy)
|
||||
(fileEvent_, acis) <- withStore $ \db -> do
|
||||
liftIO $ sentMsgDeliveryEvent db conn msgId
|
||||
fileEvent_ <- checkSndInlineFTComplete db conn msgId
|
||||
(fileEvent_,) <$> updateGroupItemsStatus db gInfo m conn msgId GSSSent (Just $ isJust proxy)
|
||||
mapM_ toView fileEvent_
|
||||
unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis
|
||||
when continued $ do
|
||||
when (isUserGrpFwdRelay gInfo) $ serveRoster user gInfo m -- roster ahead of the resumed backlog
|
||||
sendPendingGroupMessages user gInfo m conn
|
||||
@@ -1229,7 +1257,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
liftIO $ updateGroupMemberStatus db userId m GSMemAccepted
|
||||
(m', relay) <- setRelayLinkAccepted db cxt user m (MemberKey relayKey) relayProfile
|
||||
pure (confId, m', relay)
|
||||
allowAgentConnectionAsync user conn confId XOk
|
||||
allowAgentConnectionAsync user conn confId (Just gInfo) XOk
|
||||
toView $ CEvtGroupRelayUpdated user gInfo m' relay
|
||||
else
|
||||
-- TODO [relays] owner: TBC failed RelayStatus?
|
||||
@@ -1359,9 +1387,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
REQ invId pqSupport _ connInfo rejectionSupported -> do
|
||||
(signedMsg_, ChatMessage {chatVRange, chatMsgEvent}) <- parseChatMessage' conn connInfo
|
||||
case chatMsgEvent of
|
||||
XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported
|
||||
XContact p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p memberKey_ xContactId_ welcomeMsgId_ requestMsg_ pqSupport rejectionSupported
|
||||
XMember p joiningMemberId joiningMemberKey viaRelay -> memberJoinRequestViaRelay invId chatVRange signedMsg_ p joiningMemberId joiningMemberKey viaRelay
|
||||
XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport rejectionSupported
|
||||
XInfo p _ -> profileContactRequest invId chatVRange p Nothing Nothing Nothing Nothing pqSupport rejectionSupported
|
||||
XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv
|
||||
XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge
|
||||
-- TODO show/log error, other events in contact request
|
||||
@@ -1435,8 +1463,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
-- TODO add debugging output
|
||||
_ -> pure ()
|
||||
where
|
||||
profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> PQSupport -> Bool -> CM ()
|
||||
profileContactRequest invId chatVRange p@Profile {displayName} xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported = do
|
||||
profileContactRequest :: InvitationId -> VersionRangeChat -> Profile -> Maybe MemberKey -> Maybe XContactId -> Maybe SharedMsgId -> Maybe (SharedMsgId, MsgContent) -> PQSupport -> Bool -> CM ()
|
||||
profileContactRequest invId chatVRange p@Profile {displayName} memberKey_ xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported = do
|
||||
(ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId
|
||||
let v = maxVersion chatVRange
|
||||
case gLinkInfo_ of
|
||||
@@ -1589,7 +1617,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case
|
||||
Right (acceptance, useRole) -> do
|
||||
let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo
|
||||
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode Nothing Nothing
|
||||
mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode memberKey_ Nothing
|
||||
(gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem
|
||||
createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing
|
||||
toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem'
|
||||
@@ -1649,9 +1677,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
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}) ->
|
||||
(Just SignedMsg {chatBinding = CBGroup, signatures, signedBody}, Just gks) ->
|
||||
memberPubKey rosterMem == Just joiningKey
|
||||
&& verifyGroupSig joiningKey publicGroupId joiningMemberId signatures signedBody
|
||||
&& verifyGroupSig joiningKey (Just gks) joiningMemberId signatures signedBody
|
||||
&& viaRelay == Just (memberId' (membership gInfo))
|
||||
_ -> False
|
||||
acceptJoin gInfo existingMem_ acceptRole = do
|
||||
@@ -1773,9 +1801,9 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
ackMsg :: MsgMeta -> Maybe MsgReceiptInfo -> CM ()
|
||||
ackMsg MsgMeta {recipient = (msgId, _)} rcpt = withAgent $ \a -> ackMessageAsync a "" cId msgId rcpt
|
||||
|
||||
sentMsgDeliveryEvent :: Connection -> AgentMsgId -> CM ()
|
||||
sentMsgDeliveryEvent Connection {connId} msgId =
|
||||
withStore' $ \db -> updateSndMsgDeliveryStatus db connId msgId MDSSndSent
|
||||
sentMsgDeliveryEvent :: DB.Connection -> Connection -> AgentMsgId -> IO ()
|
||||
sentMsgDeliveryEvent db Connection {connId} msgId =
|
||||
updateSndMsgDeliveryStatus db connId msgId MDSSndSent
|
||||
|
||||
agentSndError :: AgentErrorType -> SndError
|
||||
agentSndError = \case
|
||||
@@ -1891,7 +1919,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
let MsgContainer {ttl = itemTTL, live = live_} = mc
|
||||
timed_ = rcvContactCITimed ct itemTTL
|
||||
live = fromMaybe False live_
|
||||
file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct
|
||||
file_ <- processFileInvitation fInv_ content (rcvDirectFileProhibited ct) $ \db -> createRcvFileTransfer db userId ct
|
||||
newChatItem (CIRcvMsgContent content, msgContentTexts content) (snd <$> file_) timed_ live
|
||||
autoAcceptFile file_
|
||||
where
|
||||
@@ -1907,36 +1935,37 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
ChatConfig {autoAcceptFileSize = sz} <- asks config
|
||||
when (sz > fileSize) $ receiveFileEvt' user ft False Nothing Nothing >>= toView
|
||||
|
||||
messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> Maybe UTCTime -> CM ()
|
||||
messageFileDescription Contact {contactId} sharedMsgId fileDescr fileExpires = do
|
||||
messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> Maybe UTCTime -> Maybe BadgeProof -> CM ()
|
||||
messageFileDescription ct@Contact {contactId} sharedMsgId fileDescr fileExpires fileBadge = do
|
||||
(fileId, aci) <- withStore $ \db -> do
|
||||
fileId <- getFileIdBySharedMsgId db userId contactId sharedMsgId
|
||||
aci <- getChatItemByFileId db cxt user fileId
|
||||
pure (fileId, aci)
|
||||
processFDMessage fileId aci fileDescr fileExpires
|
||||
binding_ <- if isJust fileBadge then directChatBinding ct else pure Nothing
|
||||
processFDMessage binding_ fileId aci fileDescr fileExpires fileBadge
|
||||
|
||||
groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> Maybe UTCTime -> CM (Maybe DeliveryTaskContext)
|
||||
groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr fileExpires = do
|
||||
groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> Maybe UTCTime -> Maybe BadgeProof -> CM (Maybe DeliveryTaskContext)
|
||||
groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr fileExpires fileBadge = do
|
||||
(fileId, aci) <- withStore $ \db -> do
|
||||
fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId
|
||||
aci <- getChatItemByFileId db cxt user fileId
|
||||
pure (fileId, aci)
|
||||
case aci of
|
||||
AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir}
|
||||
AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir, meta = CIMeta {showGroupAsSender}}
|
||||
| validSender m_ chatDir -> do
|
||||
-- in processFDMessage some paths are programmed as errors,
|
||||
-- for example failure on not approved relays (CEFileNotApproved).
|
||||
-- we catch error, so that even if processFDMessage fails, message can still be forwarded.
|
||||
processFDMessage fileId aci fileDescr fileExpires `catchAllErrors` \_ -> pure ()
|
||||
processFDMessage (rcvGroupChatBinding g m_ showGroupAsSender fileBadge) fileId aci fileDescr fileExpires fileBadge `catchAllErrors` \_ -> pure ()
|
||||
pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir)
|
||||
| otherwise -> messageError "x.msg.file.descr: file/sender mismatch" $> Nothing
|
||||
_ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing
|
||||
|
||||
processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> Maybe UTCTime -> CM ()
|
||||
processFDMessage fileId aci fileDescr fileExpires = do
|
||||
processFDMessage :: Maybe ByteString -> FileTransferId -> AChatItem -> FileDescr -> Maybe UTCTime -> Maybe BadgeProof -> CM ()
|
||||
processFDMessage binding_ fileId aci fileDescr fileExpires fileBadge = do
|
||||
ft <- withStore $ \db -> getRcvFileTransfer db user fileId
|
||||
unless (rcvFileCompleteOrCancelled ft) $ do
|
||||
(rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do
|
||||
(rfd@RcvFileDescr {fileDescrComplete}, ft'@RcvFileTransfer {fileStatus, xftpRcvFile, cryptoArgs, fileProhibited, fileInvitation = FileInvitation {fileSize}}) <- withStore $ \db -> do
|
||||
rfd <- appendRcvFD db userId fileId fileDescr
|
||||
forM_ fileExpires $ liftIO . setFileExpiration db user fileId
|
||||
-- reading second time in the same transaction as appending description
|
||||
@@ -1944,16 +1973,41 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
ft' <- getRcvFileTransfer db user fileId
|
||||
pure (rfd, ft')
|
||||
when fileDescrComplete $ toView $ CEvtRcvFileDescrReady user aci ft' rfd
|
||||
case (fileStatus, xftpRcvFile) of
|
||||
(RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd fileSize userApprovedRelays cryptoArgs
|
||||
_ -> pure ()
|
||||
maxSize <- asks $ noBadge . fileSizeLimits . config
|
||||
let descrBadgeRequired = fileDescrComplete && fileSize > maxSize && isNothing fileProhibited
|
||||
prohibited_ <- case fileBadge of
|
||||
_ | not descrBadgeRequired -> pure Nothing
|
||||
Nothing -> pure $ Just FileProhibited {maxSize, badgeStatus = Nothing}
|
||||
Just badge -> do
|
||||
st <- descrBadgeStatus binding_ fileSize rfd fileExpires badge
|
||||
if st == BSActive
|
||||
then do
|
||||
withStore' $ \db -> createFileBadgeProof db fileId BPKDescription badge
|
||||
pure Nothing
|
||||
else pure $ Just FileProhibited {maxSize, badgeStatus = Just st}
|
||||
case prohibited_ of
|
||||
Nothing -> case (fileStatus, xftpRcvFile) of
|
||||
(RFSAccepted _, Just XFTPRcvFile {userApprovedRelays}) -> receiveViaCompleteFD user fileId rfd fileSize userApprovedRelays cryptoArgs
|
||||
_ -> pure ()
|
||||
-- the file may already be accepted, so it is reset to an invitation the apps refuse by its prohibition
|
||||
Just prohibited -> do
|
||||
withStore' $ \db -> setFileProhibited db user fileId prohibited
|
||||
aci_ <- resetRcvCIFileStatus user fileId CIFSRcvInvitation
|
||||
forM_ aci_ $ \aci' -> toView $ CEvtChatItemUpdated user aci'
|
||||
|
||||
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
|
||||
descrBadgeStatus :: Maybe ByteString -> Integer -> RcvFileDescr -> Maybe UTCTime -> BadgeProof -> CM BadgeStatus
|
||||
descrBadgeStatus binding_ fileSize RcvFileDescr {fileDescrText} fileExpires badge = do
|
||||
FD.ValidFileDescription fd <- parseFileDescription @'FRecipient fileDescrText
|
||||
let descrHash = FD.sharedDescriptionHash fd
|
||||
badgeProofStatus ((\chatBinding -> PHFileDescr {chatBinding, fileSize = fromInteger fileSize, descrHash, fileExpires}) <$> binding_) badge
|
||||
|
||||
processFileInvitation :: Maybe FileInvitation -> MsgContent -> (FileInvitation -> CM (Maybe FileProhibited)) -> (DB.Connection -> FileInvitation -> Maybe FileProhibited -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer) -> CM (Maybe (RcvFileTransfer, CIFile 'MDRcv))
|
||||
processFileInvitation fInv_ mc fileProhibited_ createRcvFT = forM fInv_ $ \fInv -> do
|
||||
ChatConfig {fileChunkSize} <- asks config
|
||||
fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv
|
||||
fileProhibited <- fileProhibited_ fInv'
|
||||
inline <- receiveInlineMode fInv' (Just mc) fileChunkSize
|
||||
ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv' inline fileChunkSize
|
||||
ft@RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFT db fInv' fileProhibited inline fileChunkSize
|
||||
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
|
||||
(filePath, fileStatus, ft') <- case inline of
|
||||
Just IFMSent -> do
|
||||
@@ -1965,15 +2019,19 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
_ -> pure (Nothing, CIFSRcvInvitation, ft)
|
||||
let RcvFileTransfer {cryptoArgs} = ft'
|
||||
fileSource = (`CryptoFile` cryptoArgs) <$> filePath
|
||||
pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires = Nothing})
|
||||
pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires = Nothing, fileProhibited})
|
||||
|
||||
mkValidFileInvitation :: FileInvitation -> FileInvitation
|
||||
mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = safeFileNameStr fileName}
|
||||
|
||||
validateFileInvitation :: FileInvitation -> CM FileInvitation
|
||||
validateFileInvitation fInv@FileInvitation {fileName, fileSize}
|
||||
| fileSize > 0 = pure $ mkValidFileInvitation fInv
|
||||
| otherwise = throwChatError $ CEFileSize fileName
|
||||
validateFileInvitation fInv@FileInvitation {fileName, fileSize, fileDescr}
|
||||
| fileSize <= 0 = throwChatError $ CEFileSize fileName
|
||||
| otherwise = do
|
||||
-- a file that requires a badge is received from the description message, where the proof binds the description
|
||||
needsBadge <- fileNeedsBadge fileSize
|
||||
let fileDescr' = if needsBadge then dummyFileDescr <$ fileDescr else fileDescr
|
||||
pure $ mkValidFileInvitation fInv {fileDescr = fileDescr'}
|
||||
|
||||
messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> CM ()
|
||||
messageUpdate ct@Contact {contactId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do
|
||||
@@ -2196,7 +2254,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_ FTNormal sharedMsgId_
|
||||
in processFileInvitation fInv_ content (rcvGroupFileProhibited gInfo' m' sentAsGroup) $ \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'
|
||||
@@ -2418,10 +2476,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
processFileInvitation' ct fInv msg@RcvMessage {sharedMsgId_} msgMeta = do
|
||||
ChatConfig {fileChunkSize} <- asks config
|
||||
fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv
|
||||
fileProhibited <- rcvDirectFileProhibited ct fInv'
|
||||
inline <- receiveInlineMode fInv' Nothing fileChunkSize
|
||||
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' inline fileChunkSize
|
||||
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvFileTransfer db userId ct fInv' fileProhibited inline fileChunkSize
|
||||
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
|
||||
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing}
|
||||
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing, fileProhibited}
|
||||
content = ciContentNoParse $ CIRcvMsgContent $ MCFile ""
|
||||
(ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty
|
||||
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
|
||||
@@ -2433,10 +2492,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
processGroupFileInvitation' gInfo m fInv msg@RcvMessage {sharedMsgId_} brokerTs = do
|
||||
ChatConfig {fileChunkSize} <- asks config
|
||||
fInv'@FileInvitation {fileName, fileSize} <- validateFileInvitation fInv
|
||||
fileProhibited <- rcvGroupFileProhibited gInfo (Just m) False fInv'
|
||||
inline <- receiveInlineMode fInv' Nothing fileChunkSize
|
||||
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' inline fileChunkSize
|
||||
RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) FTNormal sharedMsgId_ fInv' fileProhibited inline fileChunkSize
|
||||
let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP
|
||||
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing}
|
||||
ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol, fileExpires = Nothing, fileProhibited}
|
||||
content = ciContentNoParse $ CIRcvMsgContent $ MCFile ""
|
||||
(ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty
|
||||
ci' <- blockedMemberCI gInfo m ci
|
||||
@@ -2505,18 +2565,17 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
imageOrVoice _ = False
|
||||
assertSMPAcceptNotProhibited _ = pure ()
|
||||
|
||||
checkSndInlineFTComplete :: Connection -> AgentMsgId -> CM ()
|
||||
checkSndInlineFTComplete conn agentMsgId = do
|
||||
sft_ <- withStore' $ \db -> getSndFTViaMsgDelivery db user conn agentMsgId
|
||||
forM_ sft_ $ \sft@SndFileTransfer {fileId} -> do
|
||||
ci@(AChatItem _ _ _ ChatItem {file}) <- withStore $ \db -> do
|
||||
liftIO $ updateSndFileStatus db sft FSComplete
|
||||
updateDirectCIFileStatus db cxt user fileId CIFSSndComplete
|
||||
checkSndInlineFTComplete :: DB.Connection -> Connection -> AgentMsgId -> ExceptT StoreError IO (Maybe ChatEvent)
|
||||
checkSndInlineFTComplete db conn agentMsgId = do
|
||||
sft_ <- liftIO $ getSndFTViaMsgDelivery db user conn agentMsgId
|
||||
forM sft_ $ \sft@SndFileTransfer {fileId} -> do
|
||||
liftIO $ updateSndFileStatus db sft FSComplete
|
||||
ci@(AChatItem _ _ _ ChatItem {file}) <- updateDirectCIFileStatus db cxt user fileId CIFSSndComplete
|
||||
case file of
|
||||
Just CIFile {fileProtocol = FPXFTP} -> do
|
||||
ft <- withStore $ \db -> getFileTransferMeta db user fileId
|
||||
toView $ CEvtSndFileCompleteXFTP user ci ft
|
||||
_ -> toView $ CEvtSndFileComplete user ci sft
|
||||
ft <- getFileTransferMeta db user fileId
|
||||
pure $ CEvtSndFileCompleteXFTP user ci ft
|
||||
_ -> pure $ CEvtSndFileComplete user ci sft
|
||||
|
||||
allowSendInline :: Integer -> Maybe InlineFileMode -> CM Bool
|
||||
allowSendInline fileSize = \case
|
||||
@@ -2618,14 +2677,15 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
|
||||
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
|
||||
memberKeys <- atomically . C.generateKeyPair =<< asks random
|
||||
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId memberKeys
|
||||
void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart)
|
||||
let GroupMember {groupMemberId, memberId = membershipMemId} = membership
|
||||
-- hostContact is only reported for group links, where the client replaces
|
||||
-- the transient host connection view with the group and removes its chat
|
||||
joinGroupAsync hostContact_ sameLink = do
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
|
||||
dm <- encodeConnInfo $ XGrpAcpt membershipMemId (groupMemberKey gInfo)
|
||||
connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest
|
||||
withStore' $ \db -> do
|
||||
when sameLink $ setViaGroupLinkUri db groupId connId
|
||||
@@ -2729,22 +2789,35 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
Profile {displayName = n, fullName = fn, shortDescr = sd, image = i, contactLink = cl} = p
|
||||
Profile {displayName = n', fullName = fn', shortDescr = sd', image = i', contactLink = cl'} = p'
|
||||
|
||||
xInfoMember :: GroupInfo -> GroupMember -> Profile -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xInfoMember gInfo m p' msg brokerTs = do
|
||||
xInfoMember :: GroupInfo -> GroupMember -> Profile -> Maybe MemberKey -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xInfoMember gInfo m p' mKey msg@RcvMessage {signedMsg_} brokerTs = do
|
||||
mapM_ (storeMemberKey gInfo m signedMsg_) mKey
|
||||
void $ processMemberProfileUpdate gInfo m p' (Just (msg, brokerTs))
|
||||
pure $ memberEventDeliveryScope m
|
||||
|
||||
xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM ()
|
||||
xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do
|
||||
xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> Maybe MemberKey -> RcvMessage -> CM ()
|
||||
xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' mKey RcvMessage {signedMsg_} = do
|
||||
xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId
|
||||
if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived
|
||||
then do
|
||||
mapM_ (storeMemberKey gInfo m signedMsg_) mKey
|
||||
m' <- processMemberProfileUpdate gInfo m p' Nothing
|
||||
withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True
|
||||
let connectedIncognito = memberIncognito membership
|
||||
probeMatchingMemberContact m' connectedIncognito
|
||||
else messageError "x.grp.link.mem error: invalid group link host profile update"
|
||||
|
||||
storeMemberKey :: GroupInfo -> GroupMember -> Maybe SignedMsg -> MemberKey -> CM ()
|
||||
storeMemberKey gInfo GroupMember {groupMemberId, memberPubKey, memberId} signedMsg_ (MemberKey k) = case memberPubKey of
|
||||
Just k0 -> when (k /= k0) $ messageError "member key change rejected, keeping current key"
|
||||
Nothing
|
||||
| signed -> withStore' $ \db -> setMemberPubKey db groupMemberId k
|
||||
| otherwise -> messageError "member key not signed by that key, ignored"
|
||||
where
|
||||
signed = case signedMsg_ of
|
||||
Just SignedMsg {chatBinding = CBGroup, signatures, signedBody} -> verifyGroupSig k (groupKeys gInfo) memberId signatures signedBody
|
||||
_ -> False
|
||||
|
||||
xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM ()
|
||||
xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs
|
||||
| memberRole' m < GRModerator || memberRole' m < role =
|
||||
@@ -2840,7 +2913,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of
|
||||
Just bc | isMainBusinessMember bc m -> do
|
||||
g' <- withStore $ \db -> updateGroupProfileFromMember db user g p'
|
||||
toView $ CEvtGroupUpdated user g g' (Just m) Nothing
|
||||
toView $ CEvtGroupUpdated user g g' (Just m) ((\(RcvMessage {msgSigned}, _) -> msgSigned) =<< msgTs_)
|
||||
_ -> pure ()
|
||||
isMainBusinessMember BusinessChatInfo {chatType, businessId, customerId} GroupMember {memberId} = case chatType of
|
||||
BCBusiness -> businessId == memberId
|
||||
@@ -3076,16 +3149,18 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage activeConn connInfo
|
||||
conn' <- updatePeerChatVRange activeConn chatVRange
|
||||
case chatMsgEvent of
|
||||
XInfo p -> do
|
||||
XInfo p _ -> do
|
||||
ct <- withStore $ \db -> createDirectContact db cxt user conn' p
|
||||
toView $ CEvtContactConnecting user ct
|
||||
pure (conn', Nothing)
|
||||
XGrpLinkInv glInv -> do
|
||||
(gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' glInv
|
||||
memberKeys <- atomically . C.generateKeyPair =<< asks random
|
||||
(gInfo, host) <- withStore $ \db -> createGroupInvitedViaLink db cxt user conn' memberKeys glInv
|
||||
toView $ CEvtGroupLinkConnecting user gInfo host
|
||||
pure (conn', Just gInfo)
|
||||
XGrpLinkReject glRjct@GroupLinkRejection {rejectionReason} -> do
|
||||
(gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' glRjct
|
||||
memberKeys <- atomically . C.generateKeyPair =<< asks random
|
||||
(gInfo, host) <- withStore $ \db -> createGroupRejectedViaLink db cxt user conn' memberKeys glRjct
|
||||
toView $ CEvtGroupLinkConnecting user gInfo host
|
||||
toViewTE $ TEGroupLinkRejected user gInfo rejectionReason
|
||||
pure (conn', Just gInfo)
|
||||
@@ -3389,7 +3464,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
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}
|
||||
let rosterFInv = FileInvitation {fileName = "roster", fileSize, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Just IFMSent, fileDescr = Nothing, fileBadge = 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
|
||||
@@ -3826,7 +3901,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
-- [incognito] send membership incognito profile
|
||||
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
|
||||
dm <- encodeConnInfo $ XInfo p Nothing
|
||||
joinAgentConnectionAsync cmdId False acId True connReq dm subMode
|
||||
createItems mCt' m' = do
|
||||
(g', m'', scopeInfo) <- mkGroupChatScope g m'
|
||||
@@ -3878,13 +3953,13 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
where
|
||||
MsgContainer {scope} = mc
|
||||
-- file description is always allowed, to allow sending files to support scope
|
||||
XMsgFileDescr sharedMsgId fileDescr fileExpires -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr fileExpires
|
||||
XMsgFileDescr sharedMsgId fileDescr fileExpires fileBadge -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr fileExpires fileBadge
|
||||
XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ ->
|
||||
void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_
|
||||
XMsgDel sharedMsgId memId scope_ _ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ False rcvMsg msgTs
|
||||
XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs
|
||||
XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId
|
||||
XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs
|
||||
XInfo p mKey -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p mKey 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 memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs
|
||||
@@ -3903,7 +3978,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author"
|
||||
|
||||
withVerifiedMsg :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a)
|
||||
withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action =
|
||||
withVerifiedMsg gInfo@GroupInfo {membership, groupKeys} scopeInfo member@GroupMember {memberPubKey, memberId} (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action =
|
||||
case verified of
|
||||
Just verifiedMsg -> Just <$> action verifiedMsg
|
||||
Nothing -> do
|
||||
@@ -3911,17 +3986,11 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
pure Nothing
|
||||
where
|
||||
verified = case signedMsg_ of
|
||||
Just sm@SignedMsg {chatBinding, signatures, signedBody}
|
||||
| GroupMember {memberPubKey = Just pubKey, memberId} <- member ->
|
||||
case chatBinding of
|
||||
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)
|
||||
Just sm@SignedMsg {chatBinding, signatures, signedBody} -> case memberPubKey of
|
||||
Just pubKey -> case chatBinding of
|
||||
CBGroup -> signed MSSVerified <$ guard (verifyGroupSig pubKey groupKeys memberId signatures signedBody)
|
||||
_ -> signed MSSSignedNoKey <$ guard signatureOptional
|
||||
Nothing -> signed MSSSignedNoKey <$ guard (signatureOptional || unverifiedAllowed membership member tag)
|
||||
where
|
||||
signed status = VMSigned status sm chatMsg
|
||||
Nothing -> VMUnsigned chatMsg <$ guard signatureOptional
|
||||
@@ -3941,8 +4010,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
(gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m
|
||||
checkIntegrityCreateItem (CDGroupRcv gInfo' scopeInfo m') msgMeta `catchAllErrors` \_ -> pure ()
|
||||
forM_ msgRcpts $ \MsgReceipt {agentMsgId, msgRcptStatus} -> do
|
||||
withStore' $ \db -> updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus
|
||||
updateGroupItemsStatus gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing
|
||||
acis <- withStore $ \db -> do
|
||||
liftIO $ updateSndMsgDeliveryStatus db connId agentMsgId $ MDSSndRcvd msgRcptStatus
|
||||
updateGroupItemsStatus db gInfo' m' conn agentMsgId (GSSRcvd msgRcptStatus) Nothing
|
||||
unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis
|
||||
|
||||
-- Searches chat items for many agent message IDs and updates their status
|
||||
updateDirectItemsStatusMsgs :: Contact -> Connection -> [AgentMsgId] -> CIStatus 'MDSnd -> CM ()
|
||||
@@ -3983,22 +4054,20 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
|
||||
| otherwise -> updateGroupSndStatus db itemId groupMemberId newStatus $> True
|
||||
_ -> pure False
|
||||
|
||||
updateGroupItemsStatus :: GroupInfo -> GroupMember -> Connection -> AgentMsgId -> GroupSndStatus -> Maybe Bool -> CM ()
|
||||
updateGroupItemsStatus gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = do
|
||||
acis <- withStore $ \db -> do
|
||||
items <- liftIO $ getGroupChatItemsByAgentMsgId db user groupId connId msgId
|
||||
cis <- catMaybes <$> mapM (updateItem db) items
|
||||
-- SENT and RCVD events are received for messages that may be batched in single scope,
|
||||
-- so we can look up scope of first item
|
||||
scopeInfo <- case cis of
|
||||
(ci : _) -> getGroupChatScopeInfoForItem db cxt user gInfo (chatItemId' ci)
|
||||
_ -> pure Nothing
|
||||
pure $ map (gItem scopeInfo) cis
|
||||
unless (null acis) $ toView $ CEvtChatItemsStatusesUpdated user acis
|
||||
updateGroupItemsStatus :: DB.Connection -> GroupInfo -> GroupMember -> Connection -> AgentMsgId -> GroupSndStatus -> Maybe Bool -> ExceptT StoreError IO [AChatItem]
|
||||
updateGroupItemsStatus db gInfo@GroupInfo {groupId} GroupMember {groupMemberId} Connection {connId} msgId newMemStatus viaProxy_ = do
|
||||
items <- liftIO $ getGroupChatItemsByAgentMsgId db user groupId connId msgId
|
||||
cis <- catMaybes <$> mapM updateItem items
|
||||
-- SENT and RCVD events are received for messages that may be batched in single scope,
|
||||
-- so we can look up scope of first item
|
||||
scopeInfo <- case cis of
|
||||
(ci : _) -> getGroupChatScopeInfoForItem db cxt user gInfo (chatItemId' ci)
|
||||
_ -> pure Nothing
|
||||
pure $ map (gItem scopeInfo) cis
|
||||
where
|
||||
gItem scopeInfo ci = AChatItem SCTGroup SMDSnd (GroupChat gInfo scopeInfo) ci
|
||||
updateItem :: DB.Connection -> CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd))
|
||||
updateItem db = \case
|
||||
updateItem :: CChatItem 'CTGroup -> ExceptT StoreError IO (Maybe (ChatItem 'CTGroup 'MDSnd))
|
||||
updateItem = \case
|
||||
(CChatItem SMDSnd ChatItem {meta = CIMeta {itemStatus = CISSndRcvd _ SSPComplete}}) -> pure Nothing
|
||||
(CChatItem SMDSnd ChatItem {meta = CIMeta {itemId, itemStatus}}) -> do
|
||||
forM_ viaProxy_ $ \viaProxy -> liftIO $ setGroupSndViaProxy db itemId groupMemberId viaProxy
|
||||
@@ -4379,7 +4448,7 @@ runRelayRequestWorker a Worker {doWork} = do
|
||||
r -> pure r
|
||||
scheduleRequest :: GroupId -> NominalDiffTime -> CM ()
|
||||
scheduleRequest groupId delay = do
|
||||
v_ <- liftIO $ atomically $
|
||||
v_ <- atomically $
|
||||
ifM
|
||||
(isNothing <$> TM.lookup groupId delayThreads)
|
||||
(newEmptyTMVar >>= \v -> TM.insert groupId v delayThreads $> Just v)
|
||||
@@ -4390,7 +4459,7 @@ runRelayRequestWorker a Worker {doWork} = do
|
||||
atomically $ TM.delete groupId delayThreads
|
||||
void $ atomically $ tryPutTMVar doWork ()
|
||||
weakTId <- liftIO $ mkWeakThreadId tId
|
||||
liftIO $ atomically $ putTMVar v weakTId
|
||||
atomically $ putTMVar v weakTId
|
||||
retryTmpError :: (Int, NominalDiffTime) -> GroupId -> RelayRequestData -> ChatError -> CM ()
|
||||
retryTmpError (retriesThreshold, ttl) groupId RelayRequestData {reqDelay, reqRetries, reqCreatedAt} = \case
|
||||
ChatErrorAgent {agentError} | temporaryOrHostError agentError -> do
|
||||
@@ -4450,7 +4519,7 @@ runRelayRequestWorker a Worker {doWork} = do
|
||||
gVar <- asks random
|
||||
groupLinkId <- GroupLinkId <$> drgRandomBytes 16
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
sigKeys <- liftIO $ atomically $ C.generateKeyPair gVar
|
||||
sigKeys <- atomically $ C.generateKeyPair gVar
|
||||
let crClientData = encodeJSON $ CRDataGroup groupLinkId
|
||||
-- prepare link with relayMemId as linkEntityId (no server request)
|
||||
(ccLink, preparedParams) <- withAgent $ \a' -> prepareConnectionLink a' (aUserId user) sigKeys relayMemId True (Just crClientData) CR.IKPQOff False Nothing
|
||||
|
||||
@@ -688,7 +688,8 @@ data CIFile (d :: MsgDirection) = CIFile
|
||||
fileSource :: Maybe CryptoFile, -- local file path with optional key and nonce
|
||||
fileStatus :: CIFileStatus d,
|
||||
fileProtocol :: FileProtocol,
|
||||
fileExpires :: Maybe UTCTime
|
||||
fileExpires :: Maybe UTCTime,
|
||||
fileProhibited :: Maybe FileProhibited
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0)
|
||||
| msgLen <= maxLen = (addBatch acc, [body], [msg], msgLen, 1)
|
||||
| otherwise = (errLarge msg : addBatch acc, [], [], 0, 0)
|
||||
where
|
||||
body = encodeBatchElement signedMsg_ msgBody
|
||||
body = encodeBatchElement (if mode == BMBinary then signedMsg_ else Nothing) msgBody
|
||||
msgLen = B.length body
|
||||
len' = len + msgLen
|
||||
n' = n + 1
|
||||
|
||||
@@ -259,6 +259,7 @@ mobileChatOpts dbOptions =
|
||||
logAgent = Nothing,
|
||||
logFile = Nothing,
|
||||
tbqSize = 4096,
|
||||
maxChats = 5000,
|
||||
deviceName = Nothing,
|
||||
chatRelay = False,
|
||||
webPreviewConfig = Nothing,
|
||||
|
||||
@@ -67,6 +67,7 @@ data CoreChatOpts = CoreChatOpts
|
||||
logAgent :: Maybe LogLevel,
|
||||
logFile :: Maybe FilePath,
|
||||
tbqSize :: Natural,
|
||||
maxChats :: Int,
|
||||
deviceName :: Maybe Text,
|
||||
chatRelay :: Bool,
|
||||
webPreviewConfig :: Maybe WebPreviewConfig,
|
||||
@@ -234,6 +235,15 @@ coreChatOptsP appDir defaultDbName = do
|
||||
<> value 1024
|
||||
<> showDefault
|
||||
)
|
||||
maxChats <-
|
||||
option
|
||||
auto
|
||||
( long "max-chats"
|
||||
<> metavar "COUNT"
|
||||
<> help "Max number of chats loaded by chat list API"
|
||||
<> value 5000
|
||||
<> showDefault
|
||||
)
|
||||
deviceName <-
|
||||
optional $
|
||||
strOption
|
||||
@@ -340,6 +350,7 @@ coreChatOptsP appDir defaultDbName = do
|
||||
logAgent = if logAgent || logLevel == CLLDebug then Just $ agentLogLevel logLevel else Nothing,
|
||||
logFile,
|
||||
tbqSize,
|
||||
maxChats,
|
||||
deviceName,
|
||||
chatRelay,
|
||||
webPreviewConfig,
|
||||
|
||||
@@ -50,7 +50,7 @@ import Data.Time.Clock.System (systemToUTCTime, utcToSystemTime)
|
||||
import Data.Type.Equality
|
||||
import Data.Typeable (Typeable)
|
||||
import Data.Word (Word16, Word32)
|
||||
import Simplex.Chat.Badges (LocalBadge)
|
||||
import Simplex.Chat.Badges (BadgeProof, LocalBadge)
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Options.DB (FromField (..), ToField (..))
|
||||
import Simplex.Chat.Types
|
||||
@@ -86,12 +86,13 @@ import Simplex.Messaging.Version hiding (version)
|
||||
-- 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)
|
||||
-- 20 - p2p group member keys for signing (2026-07-26)
|
||||
|
||||
-- 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 19
|
||||
currentChatVersion = VersionChat 20
|
||||
|
||||
-- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above)
|
||||
supportedChatVRange :: VersionRangeChat
|
||||
@@ -135,6 +136,10 @@ relayWebCapVersion = VersionChat 18
|
||||
groupRosterVersion :: VersionChat
|
||||
groupRosterVersion = VersionChat 19
|
||||
|
||||
-- members sign messages in p2p groups; member keys are distributed for verification
|
||||
groupMemberKeyVersion :: VersionChat
|
||||
groupMemberKeyVersion = VersionChat 20
|
||||
|
||||
data ConnectionEntity
|
||||
= RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact}
|
||||
| RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember}
|
||||
@@ -446,7 +451,7 @@ signChatMsgBody MsgSigning {bindingTag, bindingData, keyRef, privKey} msgBody =
|
||||
|
||||
data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XMsgNew :: MsgContainer -> ChatMsgEvent 'Json
|
||||
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr, fileExpires :: Maybe UTCTime} -> ChatMsgEvent 'Json
|
||||
XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr, fileExpires :: Maybe UTCTime, fileBadge :: Maybe BadgeProof} -> ChatMsgEvent 'Json
|
||||
XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json
|
||||
XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, onlyHistory :: Bool} -> ChatMsgEvent 'Json
|
||||
XMsgDeleted :: ChatMsgEvent 'Json
|
||||
@@ -455,15 +460,15 @@ data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XFileAcpt :: String -> ChatMsgEvent 'Json -- direct file protocol
|
||||
XFileAcptInv :: SharedMsgId -> Maybe ConnReqInvitation -> String -> ChatMsgEvent 'Json
|
||||
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
|
||||
XInfo :: Profile -> ChatMsgEvent 'Json
|
||||
XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json
|
||||
XInfo :: {profile :: Profile, memberKey :: Maybe MemberKey} -> ChatMsgEvent 'Json
|
||||
XContact :: {profile :: Profile, memberKey :: Maybe MemberKey, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> 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
|
||||
XGrpAcpt :: MemberId -> Maybe MemberKey -> ChatMsgEvent 'Json
|
||||
XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json
|
||||
XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json
|
||||
XGrpLinkMem :: Profile -> ChatMsgEvent 'Json
|
||||
XGrpLinkMem :: Profile -> Maybe MemberKey -> ChatMsgEvent 'Json
|
||||
XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json
|
||||
XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json
|
||||
XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json
|
||||
@@ -522,7 +527,7 @@ isForwardedGroupMsg ev = case ev of
|
||||
XMsgDel {} -> True
|
||||
XMsgReact {} -> True
|
||||
XFileCancel _ -> True
|
||||
XInfo _ -> True
|
||||
XInfo {} -> True
|
||||
XGrpRelayNew _ -> True
|
||||
XGrpMemNew {} -> True
|
||||
XGrpMemRole {} -> True
|
||||
@@ -1248,15 +1253,15 @@ toCMEventTag msg = case msg of
|
||||
XFileAcpt _ -> XFileAcpt_
|
||||
XFileAcptInv {} -> XFileAcptInv_
|
||||
XFileCancel _ -> XFileCancel_
|
||||
XInfo _ -> XInfo_
|
||||
XInfo {} -> XInfo_
|
||||
XContact {} -> XContact_
|
||||
XMember {} -> XMember_
|
||||
XDirectDel -> XDirectDel_
|
||||
XGrpInv _ -> XGrpInv_
|
||||
XGrpAcpt _ -> XGrpAcpt_
|
||||
XGrpAcpt {} -> XGrpAcpt_
|
||||
XGrpLinkInv _ -> XGrpLinkInv_
|
||||
XGrpLinkReject _ -> XGrpLinkReject_
|
||||
XGrpLinkMem _ -> XGrpLinkMem_
|
||||
XGrpLinkMem {} -> XGrpLinkMem_
|
||||
XGrpLinkAcpt {} -> XGrpLinkAcpt_
|
||||
XGrpRelayInv _ -> XGrpRelayInv_
|
||||
XGrpRelayAcpt {} -> XGrpRelayAcpt_
|
||||
@@ -1342,6 +1347,7 @@ requiresSignature = \case
|
||||
XGrpRelayNew_ -> True
|
||||
XGrpRoster_ -> True
|
||||
XInfo_ -> True
|
||||
XGrpLinkMem_ -> True
|
||||
_ -> False
|
||||
|
||||
-- | Content events a member may sign (XMsgNew opt-in; XMsgUpdate/XMsgDel when the target was signed).
|
||||
@@ -1391,7 +1397,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
msg :: CMEventTag 'Json -> Either String (ChatMsgEvent 'Json)
|
||||
msg = \case
|
||||
XMsgNew_ -> XMsgNew <$> JT.parseEither parseJSON (J.Object params)
|
||||
XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" <*> opt "fileExpires"
|
||||
XMsgFileDescr_ -> XMsgFileDescr <$> p "msgId" <*> p "fileDescr" <*> opt "fileExpires" <*> opt "fileBadge"
|
||||
XMsgUpdate_ -> do
|
||||
msgId' <- p "msgId"
|
||||
content <- p "content"
|
||||
@@ -1408,22 +1414,23 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XFileAcpt_ -> XFileAcpt <$> p "fileName"
|
||||
XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> opt "fileConnReq" <*> p "fileName"
|
||||
XFileCancel_ -> XFileCancel <$> p "msgId"
|
||||
XInfo_ -> XInfo <$> p "profile"
|
||||
XInfo_ -> XInfo <$> p "profile" <*> opt "memberKey"
|
||||
XContact_ -> do
|
||||
profile <- p "profile"
|
||||
memberKey <- opt "memberKey"
|
||||
contactReqId <- opt "contactReqId"
|
||||
welcomeMsgId <- opt "welcomeMsgId"
|
||||
reqMsgId <- opt "msgId"
|
||||
reqContent <- opt "content"
|
||||
let requestMsg = (,) <$> reqMsgId <*> reqContent
|
||||
pure XContact {profile, contactReqId, welcomeMsgId, requestMsg}
|
||||
pure XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg}
|
||||
XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay"
|
||||
XDirectDel_ -> pure XDirectDel
|
||||
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberKey"
|
||||
XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation"
|
||||
XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection"
|
||||
XGrpLinkMem_ -> XGrpLinkMem <$> p "profile"
|
||||
XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" <*> opt "memberKey"
|
||||
XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId"
|
||||
XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation"
|
||||
XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (fromMaybe defaultRelayCapabilities <$> opt "relayCap")
|
||||
@@ -1482,7 +1489,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XMsgNew mc -> case toJSON mc of
|
||||
J.Object obj -> obj
|
||||
_ -> JM.empty
|
||||
XMsgFileDescr msgId' fileDescr fileExpires -> o $ ("fileExpires" .=? fileExpires) ["msgId" .= msgId', "fileDescr" .= fileDescr]
|
||||
XMsgFileDescr msgId' fileDescr fileExpires fileBadge -> o $ ("fileExpires" .=? fileExpires) $ ("fileBadge" .=? fileBadge) ["msgId" .= msgId', "fileDescr" .= fileDescr]
|
||||
XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content]
|
||||
XMsgDel msgId' memberId scope onlyHistory -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) $ ("onlyHistory" .=? justTrue onlyHistory) ["msgId" .= msgId']
|
||||
XMsgDeleted -> JM.empty
|
||||
@@ -1491,15 +1498,15 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XFileAcpt fileName -> o ["fileName" .= fileName]
|
||||
XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName]
|
||||
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]
|
||||
XInfo {profile, memberKey} -> o $ ("memberKey" .=? memberKey) ["profile" .= profile]
|
||||
XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ("memberKey" .=? memberKey) $ ["profile" .= profile]
|
||||
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]
|
||||
XGrpAcpt memId memberKey -> o $ ("memberKey" .=? memberKey) ["memberId" .= memId]
|
||||
XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv]
|
||||
XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct]
|
||||
XGrpLinkMem profile -> o ["profile" .= profile]
|
||||
XGrpLinkMem profile memberKey -> o $ ("memberKey" .=? memberKey) ["profile" .= profile]
|
||||
XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId]
|
||||
XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv]
|
||||
XGrpRelayAcpt relayLink relayCap -> o ["relayLink" .= relayLink, "relayCap" .= relayCap]
|
||||
|
||||
@@ -45,6 +45,9 @@ module Simplex.Chat.Store.Files
|
||||
updateSndFileStatus,
|
||||
createRcvFileTransfer,
|
||||
createRcvGroupFileTransfer,
|
||||
createFileBadgeProof,
|
||||
getFileBadgeProofs,
|
||||
setFileProhibited,
|
||||
createRosterRcvFile,
|
||||
createRcvStandaloneFileTransfer,
|
||||
appendRcvFD,
|
||||
@@ -86,6 +89,7 @@ import Control.Monad.IO.Class
|
||||
import Data.Either (rights)
|
||||
import Data.Functor ((<&>))
|
||||
import Data.Int (Int64)
|
||||
import Data.List (foldl')
|
||||
import Data.Maybe (fromMaybe, isJust, listToMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -93,6 +97,7 @@ import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime, nominalDay)
|
||||
import Data.Type.Equality
|
||||
import Data.Word (Word32)
|
||||
import Simplex.Chat.Badges (BadgeProof, BadgeProofKind (..), BadgeProofRow, BadgeStatus (..), badgeProofToRow, rowToBadgeProof)
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Store.Messages
|
||||
@@ -180,7 +185,7 @@ getSndFTViaMsgDelivery db User {userId} Connection {connId, agentConnId} agentMs
|
||||
<$> (contactName_ <|> memberName_)
|
||||
|
||||
createSndFileTransferXFTP :: DB.Connection -> User -> Maybe ContactOrGroup -> CryptoFile -> FileInvitation -> AgentSndFileId -> Maybe FileTransferId -> Integer -> IO FileTransferMeta
|
||||
createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize} agentSndFileId xftpRedirectFor chunkSize = do
|
||||
createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath cryptoArgs) FileInvitation {fileName, fileSize, fileBadge} agentSndFileId xftpRedirectFor chunkSize = do
|
||||
currentTs <- getCurrentTime
|
||||
let xftpSndFile = Just XFTPSndFile {agentSndFileId, privateSndFileDescr = Nothing, agentSndFileDeleted = False, cryptoArgs}
|
||||
DB.execute
|
||||
@@ -188,6 +193,7 @@ createSndFileTransferXFTP db User {userId} contactOrGroup_ (CryptoFile filePath
|
||||
"INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, redirect_file_id, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
(maybe (Nothing, Nothing) contactAndGroupIds contactOrGroup_ :. (userId, fileName, filePath, CF.fileKey <$> cryptoArgs, CF.fileNonce <$> cryptoArgs, fileSize, chunkSize) :. (xftpRedirectFor, agentSndFileId, CIFSSndStored, FPXFTP, currentTs, currentTs))
|
||||
fileId <- insertedRowId db
|
||||
forM_ fileBadge $ createFileBadgeProof db fileId BPKInvitation
|
||||
pure FileTransferMeta {fileId, xftpSndFile, xftpRedirectFor, fileName, filePath, fileSize, fileInline = Nothing, chunkSize, cancelled = False}
|
||||
|
||||
createSndFTDescrXFTP :: DB.Connection -> User -> Maybe GroupMember -> Connection -> FileTransferMeta -> FileDescr -> IO ()
|
||||
@@ -445,8 +451,8 @@ updateSndFileStatus db SndFileTransfer {fileId, connId} status = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE snd_files SET file_status = ?, updated_at = ? WHERE file_id = ? AND connection_id = ?" (status, currentTs, fileId, connId)
|
||||
|
||||
createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
|
||||
createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
|
||||
createRcvFileTransfer :: DB.Connection -> UserId -> Contact -> FileInvitation -> Maybe FileProhibited -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
|
||||
createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr, fileBadge} prohibited_ rcvFileInline chunkSize = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr
|
||||
let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_
|
||||
@@ -456,18 +462,57 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File
|
||||
fileId <- liftIO $ do
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)"
|
||||
(userId, contactId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, currentTs, currentTs)
|
||||
"INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_max_size, file_badge_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((userId, contactId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol) :. prohibitedRow prohibited_ :. (currentTs, currentTs))
|
||||
insertedRowId db
|
||||
liftIO $
|
||||
liftIO $ do
|
||||
DB.execute
|
||||
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, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
|
||||
forM_ (storedBadge prohibited_ fileBadge) $ createFileBadgeProof db fileId BPKInvitation
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileProhibited = prohibited_, fileStatus = RFSNew, fileType = FTNormal, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing}
|
||||
|
||||
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, fileDigest, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do
|
||||
setFileProhibited :: DB.Connection -> User -> Int64 -> FileProhibited -> IO ()
|
||||
setFileProhibited db User {userId} fileId FileProhibited {maxSize, badgeStatus} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE files SET file_max_size = ?, file_badge_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?"
|
||||
(maxSize, badgeStatus, currentTs, userId, fileId)
|
||||
|
||||
prohibitedRow :: Maybe FileProhibited -> (Maybe Integer, Maybe BadgeStatus)
|
||||
prohibitedRow = \case
|
||||
Just FileProhibited {maxSize, badgeStatus} -> (Just maxSize, badgeStatus)
|
||||
Nothing -> (Nothing, Nothing)
|
||||
|
||||
-- a proof that did not verify is not stored - files.file_badge_status records that it failed
|
||||
storedBadge :: Maybe FileProhibited -> Maybe BadgeProof -> Maybe BadgeProof
|
||||
storedBadge prohibited_ badge_ = case prohibited_ of
|
||||
Just FileProhibited {badgeStatus = Just st} | st /= BSActive -> Nothing
|
||||
_ -> badge_
|
||||
|
||||
createFileBadgeProof :: DB.Connection -> Int64 -> BadgeProofKind -> BadgeProof -> IO ()
|
||||
createFileBadgeProof db fileId kind badge = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO file_badge_proofs (file_id, proof_kind, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (file_id, proof_kind) DO UPDATE SET
|
||||
badge_proof = excluded.badge_proof,
|
||||
badge_pres_header = excluded.badge_pres_header,
|
||||
badge_key_idx = excluded.badge_key_idx,
|
||||
badge_type = excluded.badge_type,
|
||||
badge_expiry = excluded.badge_expiry,
|
||||
badge_extra = excluded.badge_extra,
|
||||
updated_at = excluded.updated_at
|
||||
|]
|
||||
((fileId, kind) :. badgeProofToRow badge :. (currentTs, currentTs))
|
||||
|
||||
createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileType -> Maybe SharedMsgId -> FileInvitation -> Maybe FileProhibited -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer
|
||||
createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ fileType sharedMsgId_ f@FileInvitation {fileName, fileSize, fileDigest, fileConnReq, fileInline, fileDescr, fileBadge} prohibited_ rcvFileInline chunkSize = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr
|
||||
let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_
|
||||
@@ -479,15 +524,16 @@ 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, file_type, shared_msg_id, created_at, updated_at, file_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) :. Only fileDigest)
|
||||
"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, file_digest, file_max_size, file_badge_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((userId, groupId, fileName, fileSize, chunkSize, fileInline, CIFSRcvInvitation, fileProtocol, fileType, sharedMsgId_, currentTs, currentTs) :. Only fileDigest :. prohibitedRow prohibited_)
|
||||
insertedRowId db
|
||||
liftIO $
|
||||
liftIO $ do
|
||||
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, fileType, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing}
|
||||
forM_ (storedBadge prohibited_ fileBadge) $ createFileBadgeProof db fileId BPKInvitation
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileProhibited = prohibited_, 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.
|
||||
@@ -506,7 +552,7 @@ createRosterRcvFile db userId GroupInfo {groupId} src@GroupMember {localDisplayN
|
||||
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}
|
||||
pure RcvFileTransfer {fileId, xftpRcvFile = Nothing, fileInvitation = f, fileProhibited = Nothing, 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
|
||||
@@ -586,7 +632,7 @@ rcvFileDescrWithinLimits partNo descrText =
|
||||
&& T.length descrText <= maxRcvFileDescrTextLength
|
||||
|
||||
getRcvFileDescrByRcvFileId :: DB.Connection -> FileTransferId -> ExceptT StoreError IO RcvFileDescr
|
||||
getRcvFileDescrByRcvFileId db fileId = do
|
||||
getRcvFileDescrByRcvFileId db fileId =
|
||||
liftIO (getRcvFileDescrByRcvFileId_ db fileId) >>= \case
|
||||
Nothing -> throwError $ SERcvFileDescrNotFound fileId
|
||||
Just rfd -> pure rfd
|
||||
@@ -625,6 +671,19 @@ getRcvFileDescrBySndFileId_ db fileId =
|
||||
|]
|
||||
(Only fileId)
|
||||
|
||||
getFileBadgeProofs :: DB.Connection -> Int64 -> IO (Maybe BadgeProof, Maybe BadgeProof)
|
||||
getFileBadgeProofs db fileId = foldl' addProof (Nothing, Nothing) <$> DB.query db q (Only fileId)
|
||||
where
|
||||
q =
|
||||
[sql|
|
||||
SELECT proof_kind, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra
|
||||
FROM file_badge_proofs
|
||||
WHERE file_id = ?
|
||||
|]
|
||||
addProof (inv_, descr_) (Only kind :. row) = case kind of
|
||||
BPKInvitation -> (rowToBadgeProof row, descr_)
|
||||
BPKDescription -> (inv_, rowToBadgeProof row)
|
||||
|
||||
toRcvFileDescr :: (Int64, Text, Int, BoolInt) -> RcvFileDescr
|
||||
toRcvFileDescr (fileDescrId, fileDescrText, fileDescrPartNo, BI fileDescrComplete) =
|
||||
RcvFileDescr {fileDescrId, fileDescrText, fileDescrPartNo, fileDescrComplete}
|
||||
@@ -652,7 +711,8 @@ 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, f.file_type, f.file_digest
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type, f.file_digest,
|
||||
f.file_max_size, f.file_badge_status
|
||||
FROM rcv_files r
|
||||
JOIN files f USING (file_id)
|
||||
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
|
||||
@@ -666,9 +726,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) :. (Maybe ContactName, FileType, Maybe FileDigest) ->
|
||||
(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, Maybe FileDigest, Maybe Integer, Maybe BadgeStatus) ->
|
||||
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) :. (groupName_, fileType, fileDigest_)) =
|
||||
rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. (groupName_, fileType, fileDigest_, fileMaxSize_, fileBadgeStatus_)) =
|
||||
case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just name ->
|
||||
@@ -683,10 +743,11 @@ getRcvFileTransfer_ db userId fileId = do
|
||||
(Just _, Just _) -> Just "" -- filePath marks files that are accepted from contact or, in this case, set by createRcvDirectFileTransfer
|
||||
_ -> Nothing
|
||||
ft senderDisplayName fileStatus =
|
||||
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = fileDigest_, fileConnReq, fileInline, fileDescr = Nothing}
|
||||
let fileInvitation = FileInvitation {fileName, fileSize, fileDigest = fileDigest_, fileConnReq, fileInline, fileDescr = Nothing, fileBadge = Nothing}
|
||||
fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_
|
||||
cryptoArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId, agentRcvFileDeleted, userApprovedRelays}) <$> rfd_
|
||||
in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
|
||||
in RcvFileTransfer {fileId, xftpRcvFile, fileInvitation, fileProhibited, fileStatus, fileType, rcvFileInline, senderDisplayName, chunkSize, cancelled, grpMemberId, cryptoArgs}
|
||||
filePath = case filePath_ of
|
||||
Nothing -> throwError $ SERcvFileInvalid fileId
|
||||
Just fp -> pure fp
|
||||
|
||||
@@ -107,6 +107,8 @@ module Simplex.Chat.Store.Groups
|
||||
deleteRosterTransfer,
|
||||
deleteGroupRosterTransfers,
|
||||
setGroupMemberKeyRole,
|
||||
setUserMemberKey,
|
||||
setMemberPubKey,
|
||||
setGroupMemberVerified,
|
||||
createRelayForOwner,
|
||||
getCreateRelayForMember,
|
||||
@@ -387,10 +389,12 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays
|
||||
withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do
|
||||
let (rootPrivKey_, rootPubKey_, memberPrivKey_) = case groupKeys of
|
||||
Nothing -> (Nothing, Nothing, Nothing)
|
||||
Just GroupKeys {groupRootKey, memberPrivKey} ->
|
||||
let (rpk, rpub) = case groupRootKey of
|
||||
GRKPrivate pk -> (Just pk, Nothing)
|
||||
GRKPublic k -> (Nothing, Just k)
|
||||
Just GroupKeys {publicGroupKeys, memberPrivKey} ->
|
||||
let (rpk, rpub) = case publicGroupKeys of
|
||||
Just PublicGroupKeys {groupRootKey} -> case groupRootKey of
|
||||
GRKPrivate pk -> (Just pk, Nothing)
|
||||
GRKPublic k -> (Nothing, Just k)
|
||||
Nothing -> (Nothing, Nothing)
|
||||
in (rpk, rpub, Just memberPrivKey)
|
||||
groupId <- liftIO $ do
|
||||
DB.execute
|
||||
@@ -452,9 +456,9 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile useRelays
|
||||
}
|
||||
|
||||
-- | creates a new group record for the group the current user was invited to, or returns an existing one
|
||||
createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||
createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName
|
||||
createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile, business} incognitoProfileId = do
|
||||
createGroupInvitation :: DB.Connection -> StoreCxt -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> C.KeyPairEd25519 -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
|
||||
createGroupInvitation _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ = throwError $ SEContactNotReady localDisplayName
|
||||
createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, activeConn = Just Connection {peerChatVRange}} GroupInvitation {fromMember, fromMemberKey, invitedMember, connRequest, groupProfile, business} incognitoProfileId memberKeys = do
|
||||
liftIO getInvitationGroupId_ >>= \case
|
||||
Nothing -> createGroupInvitation_
|
||||
Just gId -> do
|
||||
@@ -492,14 +496,14 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti
|
||||
[sql|
|
||||
INSERT INTO groups
|
||||
(group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs,
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, member_priv_key, business_chat, business_member_id, customer_member_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business)
|
||||
((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs, snd memberKeys) :. businessChatInfoRow business)
|
||||
insertedRowId db
|
||||
let hostVRange = adjustedMemberVRange (vr cxt) peerChatVRange
|
||||
GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing Nothing currentTs hostVRange
|
||||
membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs (vr cxt)
|
||||
GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing ((\(MemberKey k) -> k) <$> fromMemberKey) currentTs hostVRange
|
||||
membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId (Just $ fst memberKeys) currentTs (vr cxt)
|
||||
let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False}
|
||||
pure
|
||||
( GroupInfo
|
||||
@@ -526,7 +530,7 @@ createGroupInvitation db cxt user@User {userId} contact@Contact {contactId, acti
|
||||
customData = Nothing,
|
||||
membersRequireAttention = 0,
|
||||
viaGroupLinkUri = Nothing,
|
||||
groupKeys = Nothing,
|
||||
groupKeys = Just GroupKeys {publicGroupKeys = Nothing, memberPrivKey = snd memberKeys},
|
||||
groupDomainVerified = Nothing
|
||||
},
|
||||
groupMemberId
|
||||
@@ -647,8 +651,9 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile {
|
||||
createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> StoreCxt -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> Maybe Int64 -> Maybe SimplexDomain -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember)
|
||||
createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole publicMemberCount_ verifiedDomain = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
(memberPubKey, memberPrivKey) <- atomically $ C.generateKeyPair gVar
|
||||
let prepared = Just (connLinkToConnect, welcomeSharedMsgId)
|
||||
(groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ currentTs
|
||||
(groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing publicMemberCount_ (Just memberPrivKey) currentTs
|
||||
hostMemberId_ <-
|
||||
if useRelays
|
||||
then pure Nothing
|
||||
@@ -658,8 +663,7 @@ createPreparedGroup db gVar cxt user@User {userId, userContactId} groupProfile b
|
||||
then liftIO $ MemberId <$> encodedRandomBytes gVar 12
|
||||
else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id"
|
||||
let userMember = MemberIdRole userMemberId userMemberRole
|
||||
-- TODO [member keys] user key must be included here. Should key be added when group is prepared?
|
||||
membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs (vr cxt)
|
||||
membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing (Just memberPubKey) currentTs (vr cxt)
|
||||
hostMember_ <- forM hostMemberId_ $ getGroupMember db cxt user groupId
|
||||
forM_ hostMember_ $ \hostMember ->
|
||||
when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember
|
||||
@@ -781,10 +785,12 @@ updatePreparedGroupUser db cxt user gInfo@GroupInfo {groupId, membership} hostMe
|
||||
safeDeleteLDN db user oldHostLDN
|
||||
|
||||
updatePreparedUserAndHostMembersInvited :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
updatePreparedUserAndHostMembersInvited db cxt user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do
|
||||
updatePreparedUserAndHostMembersInvited db cxt user gInfo hostMember GroupLinkInvitation {fromMember, fromMemberKey, fromMemberName, invitedMember, groupProfile, accepted, business} = do
|
||||
let fromMemberProfile = profileFromName fromMemberName
|
||||
initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted
|
||||
updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus
|
||||
r@(_, hostMember') <- updatePreparedUserAndHostMembers' db cxt user gInfo hostMember fromMember fromMemberProfile invitedMember groupProfile business initialStatus
|
||||
forM_ fromMemberKey $ \(MemberKey k) -> liftIO $ setMemberPubKey db (groupMemberId' hostMember') k
|
||||
pure r
|
||||
|
||||
updatePreparedUserAndHostMembersRejected :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupMember -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
updatePreparedUserAndHostMembersRejected db cxt user gInfo hostMember GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do
|
||||
@@ -846,36 +852,37 @@ updatePreparedUserAndHostMembers'
|
||||
(memberId, memberRole, currentTs, gmId)
|
||||
getGroupMemberById db cxt user gmId
|
||||
|
||||
createGroupInvitedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
createGroupInvitedViaLink db cxt user conn GroupLinkInvitation {fromMember, fromMemberName, invitedMember, groupProfile, accepted, business} = do
|
||||
createGroupInvitedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> C.KeyPairEd25519 -> GroupLinkInvitation -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
createGroupInvitedViaLink db cxt user conn memberKeys GroupLinkInvitation {fromMember, fromMemberKey, fromMemberName, invitedMember, groupProfile, accepted, business} = do
|
||||
let fromMemberProfile = profileFromName fromMemberName
|
||||
initialStatus = maybe GSMemAccepted (acceptanceToStatus $ memberAdmission groupProfile) accepted
|
||||
createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile business initialStatus
|
||||
createGroupViaLink' db cxt user conn memberKeys fromMember fromMemberProfile ((\(MemberKey k) -> k) <$> fromMemberKey) invitedMember groupProfile business initialStatus
|
||||
|
||||
createGroupRejectedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
createGroupRejectedViaLink db cxt user conn GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do
|
||||
createGroupRejectedViaLink :: DB.Connection -> StoreCxt -> User -> Connection -> C.KeyPairEd25519 -> GroupLinkRejection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
createGroupRejectedViaLink db cxt user conn memberKeys GroupLinkRejection {fromMember = fromMember@MemberIdRole {memberId}, invitedMember, groupProfile} = do
|
||||
let fromMemberProfile = profileFromName $ nameFromMemberId memberId
|
||||
createGroupViaLink' db cxt user conn fromMember fromMemberProfile invitedMember groupProfile Nothing GSMemRejected
|
||||
createGroupViaLink' db cxt user conn memberKeys fromMember fromMemberProfile Nothing invitedMember groupProfile Nothing GSMemRejected
|
||||
|
||||
createGroupViaLink' :: DB.Connection -> StoreCxt -> User -> Connection -> MemberIdRole -> Profile -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
createGroupViaLink' :: DB.Connection -> StoreCxt -> User -> Connection -> C.KeyPairEd25519 -> MemberIdRole -> Profile -> Maybe C.PublicKeyEd25519 -> MemberIdRole -> GroupProfile -> Maybe BusinessChatInfo -> GroupMemberStatus -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
createGroupViaLink'
|
||||
db
|
||||
cxt
|
||||
user@User {userId, userContactId}
|
||||
Connection {connId, customUserProfileId}
|
||||
memberKeys
|
||||
fromMember
|
||||
fromMemberProfile
|
||||
fromMemberPubKey_
|
||||
invitedMember
|
||||
groupProfile
|
||||
business
|
||||
membershipStatus = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
(groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing Nothing currentTs
|
||||
(groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing Nothing (Just (snd memberKeys)) currentTs
|
||||
hostMemberId <- insertHost_ currentTs groupId
|
||||
liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId)
|
||||
-- using IBUnknown since host is created without contact
|
||||
-- TODO [member keys] this is currently not used with public groups. If it needs to be used, member keys need to be added
|
||||
void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs (vr cxt)
|
||||
_membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId (Just (fst memberKeys)) currentTs (vr cxt)
|
||||
liftIO $ setViaGroupLinkUri db groupId connId
|
||||
(,) <$> getGroupInfo db cxt user groupId <*> getGroupMemberById db cxt user hostMemberId
|
||||
where
|
||||
@@ -889,16 +896,16 @@ createGroupViaLink'
|
||||
[sql|
|
||||
INSERT INTO group_members
|
||||
( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
|
||||
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown)
|
||||
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs)
|
||||
:. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, fromMemberPubKey_, currentTs, currentTs)
|
||||
)
|
||||
insertedRowId db
|
||||
|
||||
createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> Maybe Int64 -> UTCTime -> ExceptT StoreError IO (GroupId, Text)
|
||||
createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus publicMemberCount_ currentTs = ExceptT $ do
|
||||
createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> Maybe Int64 -> Maybe C.PrivateKeyEd25519 -> UTCTime -> ExceptT StoreError IO (GroupId, Text)
|
||||
createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus publicMemberCount_ memberPrivKey_ currentTs = ExceptT $ do
|
||||
let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile
|
||||
(groupType_, groupLink_, publicGroupId_) = case publicGroup of
|
||||
Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId)
|
||||
@@ -924,10 +931,10 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p
|
||||
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)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count, member_priv_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus, publicMemberCount_))
|
||||
((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus, publicMemberCount_, memberPrivKey_))
|
||||
groupId <- insertedRowId db
|
||||
pure (groupId, localDisplayName)
|
||||
|
||||
@@ -1688,6 +1695,17 @@ 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)
|
||||
|
||||
setUserMemberKey :: DB.Connection -> GroupId -> GroupMemberId -> C.PrivateKeyEd25519 -> IO ()
|
||||
setUserMemberKey db groupId membershipId memberPrivKey = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE groups SET member_priv_key = ?, updated_at = ? WHERE group_id = ?" (memberPrivKey, currentTs, groupId)
|
||||
DB.execute db "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" (C.publicKey memberPrivKey, currentTs, membershipId)
|
||||
|
||||
setMemberPubKey :: DB.Connection -> GroupMemberId -> C.PublicKeyEd25519 -> IO ()
|
||||
setMemberPubKey db groupMemberId pubKey = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" (pubKey, currentTs, groupMemberId)
|
||||
|
||||
setGroupMemberVerified :: DB.Connection -> User -> GroupMemberId -> Maybe Text -> IO ()
|
||||
setGroupMemberVerified db User {userId} groupMemberId code = do
|
||||
updatedAt <- getCurrentTime
|
||||
@@ -1896,7 +1914,7 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb
|
||||
groupPreferences = Nothing,
|
||||
memberAdmission = Nothing
|
||||
}
|
||||
(groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing currentTs
|
||||
(groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing Nothing currentTs
|
||||
-- Store relay request data for recovery
|
||||
liftIO $ setRelayRequestData_ groupId currentTs
|
||||
ownerMemberId <- insertOwner_ currentTs groupId
|
||||
@@ -2151,6 +2169,7 @@ createBusinessRequestGroup
|
||||
pure (groupInfo, clientMember)
|
||||
where
|
||||
insertGroup_ currentTs = do
|
||||
(memberPubKey, memberPrivKey) <- atomically $ C.generateKeyPair gVar
|
||||
liftIO $
|
||||
DB.execute
|
||||
db
|
||||
@@ -2163,14 +2182,13 @@ createBusinessRequestGroup
|
||||
[sql|
|
||||
INSERT INTO groups
|
||||
(group_profile_id, local_display_name, user_id, enable_ntfs,
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, member_priv_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
(groupProfileId, ldn, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer)
|
||||
(groupProfileId, ldn, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer, memberPrivKey)
|
||||
groupId <- liftIO $ insertedRowId db
|
||||
memberId <- liftIO $ encodedRandomBytes gVar 12
|
||||
-- TODO [member keys] we could support member keys in business groups to allow binding agreements (though identity keys would be better for it.
|
||||
membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs (vr cxt)
|
||||
membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing (Just memberPubKey) currentTs (vr cxt)
|
||||
pure (groupId, membership)
|
||||
VersionRange minV maxV = cReqChatVRange
|
||||
insertClientMember_ currentTs groupId membership =
|
||||
|
||||
@@ -164,6 +164,7 @@ import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Simplex.Chat.Badges (BadgeStatus)
|
||||
import Simplex.Chat.Controller (ChatListQuery (..), ChatPagination (..), PaginationByTime (..))
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.Messages
|
||||
@@ -1093,7 +1094,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do
|
||||
|
||||
-- this function can be changed so it never fails, not only avoid failure on invalid json
|
||||
toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal)
|
||||
toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)) =
|
||||
toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires) :. (fileMaxSize_, fileBadgeStatus_)) =
|
||||
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
|
||||
where
|
||||
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
|
||||
@@ -1113,7 +1114,8 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex
|
||||
(Just fileId, Just fileName, Just fileSize, Just fileProtocol) ->
|
||||
let cfArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
fileSource = (`CryptoFile` cfArgs) <$> filePath
|
||||
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires}
|
||||
fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_
|
||||
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires, fileProhibited}
|
||||
_ -> Nothing
|
||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTLocal d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTLocal
|
||||
cItem d chatDir ciStatus content file =
|
||||
@@ -2276,7 +2278,7 @@ updateLocalChatItemsRead db User {userId} noteFolderId = do
|
||||
|]
|
||||
(CISRcvRead, currentTs, userId, noteFolderId, CISRcvNew)
|
||||
|
||||
type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol, Maybe UTCTime)
|
||||
type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe ACIFileStatus, Maybe FileProtocol, Maybe UTCTime) :. (Maybe Integer, Maybe BadgeStatus)
|
||||
|
||||
type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified)
|
||||
|
||||
@@ -2304,7 +2306,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir
|
||||
|
||||
-- this function can be changed so it never fails, not only avoid failure on invalid json
|
||||
toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect)
|
||||
toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)) :. quoteRow) =
|
||||
toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sentViaProxy, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires) :. (fileMaxSize_, fileBadgeStatus_)) :. quoteRow) =
|
||||
chatItem $ fromRight invalid $ dbParseACIContent itemContentText
|
||||
where
|
||||
invalid = ACIContent msgDir $ CIInvalidJSON itemContentText
|
||||
@@ -2324,7 +2326,8 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT
|
||||
(Just fileId, Just fileName, Just fileSize, Just fileProtocol) ->
|
||||
let cfArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
fileSource = (`CryptoFile` cfArgs) <$> filePath
|
||||
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires}
|
||||
fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_
|
||||
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires, fileProhibited}
|
||||
_ -> Nothing
|
||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect
|
||||
cItem d chatDir ciStatus content file =
|
||||
@@ -2380,6 +2383,7 @@ toGroupChatItem
|
||||
:. forwardedFromRow
|
||||
:. (timedTTL, timedDeleteAt, itemLive, BI userMention, BI hasLink, msgSigned)
|
||||
:. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_, fileExpires)
|
||||
:. (fileMaxSize_, fileBadgeStatus_)
|
||||
)
|
||||
:. (forwardedByMember, BI showGroupAsSender)
|
||||
:. memberRow_
|
||||
@@ -2414,7 +2418,8 @@ toGroupChatItem
|
||||
(Just fileId, Just fileName, Just fileSize, Just fileProtocol) ->
|
||||
let cfArgs = CFArgs <$> fileKey <*> fileNonce
|
||||
fileSource = (`CryptoFile` cfArgs) <$> filePath
|
||||
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires}
|
||||
fileProhibited = (\maxSize -> FileProhibited {maxSize, badgeStatus = fileBadgeStatus_}) <$> fileMaxSize_
|
||||
in Just CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol, fileExpires, fileProhibited}
|
||||
_ -> Nothing
|
||||
cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup
|
||||
cItem d chatDir ciStatus content file =
|
||||
@@ -2705,7 +2710,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do
|
||||
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
|
||||
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status,
|
||||
-- DirectQuote
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
@@ -3099,7 +3104,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
|
||||
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
|
||||
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status,
|
||||
-- CIMeta forwardedByMember, showGroupAsSender
|
||||
i.forwarded_by_group_member_id, i.show_group_as_sender,
|
||||
-- GroupMember
|
||||
@@ -3212,7 +3217,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do
|
||||
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
|
||||
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ?
|
||||
|
||||
@@ -49,6 +49,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejectio
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20261001_user_badges
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
@@ -99,6 +100,7 @@ schemaMigrations =
|
||||
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations),
|
||||
("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link),
|
||||
("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry),
|
||||
("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges),
|
||||
("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Text.RawString.QQ (r)
|
||||
|
||||
m20260904_file_badges :: Text
|
||||
m20260904_file_badges =
|
||||
[r|
|
||||
ALTER TABLE files ADD COLUMN file_max_size BIGINT;
|
||||
ALTER TABLE files ADD COLUMN file_badge_status TEXT;
|
||||
|
||||
CREATE TABLE file_badge_proofs(
|
||||
badge_proof_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
proof_kind TEXT NOT NULL,
|
||||
badge_proof BYTEA NOT NULL,
|
||||
badge_pres_header BYTEA NOT NULL,
|
||||
badge_key_idx BIGINT NOT NULL,
|
||||
badge_type TEXT NOT NULL,
|
||||
badge_expiry TIMESTAMPTZ NOT NULL,
|
||||
badge_extra TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(file_id, proof_kind);
|
||||
|]
|
||||
|
||||
down_m20260904_file_badges :: Text
|
||||
down_m20260904_file_badges =
|
||||
[r|
|
||||
DROP INDEX idx_file_badge_proofs_file_id_kind;
|
||||
DROP TABLE file_badge_proofs;
|
||||
|
||||
ALTER TABLE files DROP COLUMN file_badge_status;
|
||||
ALTER TABLE files DROP COLUMN file_max_size;
|
||||
|]
|
||||
@@ -899,6 +899,33 @@ ALTER TABLE test_chat_schema.extra_xftp_file_descriptions ALTER COLUMN extra_fil
|
||||
|
||||
|
||||
|
||||
CREATE TABLE test_chat_schema.file_badge_proofs (
|
||||
badge_proof_id bigint NOT NULL,
|
||||
file_id bigint NOT NULL,
|
||||
proof_kind text NOT NULL,
|
||||
badge_proof bytea NOT NULL,
|
||||
badge_pres_header bytea NOT NULL,
|
||||
badge_key_idx bigint NOT NULL,
|
||||
badge_type text NOT NULL,
|
||||
badge_expiry timestamp with time zone NOT NULL,
|
||||
badge_extra text NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
ALTER TABLE test_chat_schema.file_badge_proofs ALTER COLUMN badge_proof_id ADD GENERATED ALWAYS AS IDENTITY (
|
||||
SEQUENCE NAME test_chat_schema.file_badge_proofs_badge_proof_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE test_chat_schema.files (
|
||||
file_id bigint NOT NULL,
|
||||
contact_id bigint,
|
||||
@@ -926,7 +953,9 @@ CREATE TABLE test_chat_schema.files (
|
||||
file_type text DEFAULT 'normal'::text NOT NULL,
|
||||
roster_transfer_id bigint,
|
||||
file_digest bytea,
|
||||
file_expires_at timestamp with time zone
|
||||
file_expires_at timestamp with time zone,
|
||||
file_max_size bigint,
|
||||
file_badge_status text
|
||||
);
|
||||
|
||||
|
||||
@@ -1955,6 +1984,11 @@ ALTER TABLE ONLY test_chat_schema.extra_xftp_file_descriptions
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY test_chat_schema.file_badge_proofs
|
||||
ADD CONSTRAINT file_badge_proofs_pkey PRIMARY KEY (badge_proof_id);
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY test_chat_schema.files
|
||||
ADD CONSTRAINT files_pkey PRIMARY KEY (file_id);
|
||||
|
||||
@@ -2688,6 +2722,10 @@ CREATE INDEX idx_extra_xftp_file_descriptions_user_id ON test_chat_schema.extra_
|
||||
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON test_chat_schema.file_badge_proofs USING btree (file_id, proof_kind);
|
||||
|
||||
|
||||
|
||||
CREATE INDEX idx_files_chat_item_id ON test_chat_schema.files USING btree (chat_item_id);
|
||||
|
||||
|
||||
@@ -3429,6 +3467,11 @@ ALTER TABLE ONLY test_chat_schema.extra_xftp_file_descriptions
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY test_chat_schema.file_badge_proofs
|
||||
ADD CONSTRAINT file_badge_proofs_file_id_fkey FOREIGN KEY (file_id) REFERENCES test_chat_schema.files(file_id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY test_chat_schema.files
|
||||
ADD CONSTRAINT files_contact_id_fkey FOREIGN KEY (contact_id) REFERENCES test_chat_schema.contacts(contact_id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20261001_user_badges
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
@@ -345,6 +346,7 @@ schemaMigrations =
|
||||
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations),
|
||||
("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link),
|
||||
("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry),
|
||||
("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges),
|
||||
("20261001_user_badges", m20261001_user_badges, Just down_m20261001_user_badges)
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20260904_file_badges :: Query
|
||||
m20260904_file_badges =
|
||||
[sql|
|
||||
ALTER TABLE files ADD COLUMN file_max_size INTEGER;
|
||||
ALTER TABLE files ADD COLUMN file_badge_status TEXT;
|
||||
|
||||
CREATE TABLE file_badge_proofs(
|
||||
badge_proof_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
proof_kind TEXT NOT NULL,
|
||||
badge_proof BLOB NOT NULL,
|
||||
badge_pres_header BLOB NOT NULL,
|
||||
badge_key_idx INTEGER NOT NULL,
|
||||
badge_type TEXT NOT NULL,
|
||||
badge_expiry TEXT NOT NULL,
|
||||
badge_extra TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(file_id, proof_kind);
|
||||
|]
|
||||
|
||||
down_m20260904_file_badges :: Query
|
||||
down_m20260904_file_badges =
|
||||
[sql|
|
||||
DROP INDEX idx_file_badge_proofs_file_id_kind;
|
||||
DROP TABLE file_badge_proofs;
|
||||
|
||||
ALTER TABLE files DROP COLUMN file_badge_status;
|
||||
ALTER TABLE files DROP COLUMN file_max_size;
|
||||
|]
|
||||
@@ -115,8 +115,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
|
||||
Query:
|
||||
INSERT INTO groups
|
||||
(group_profile_id, local_display_name, inv_queue_info, user_id, enable_ntfs,
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, business_member_id, customer_member_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, member_priv_key, business_chat, business_member_id, customer_member_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
|
||||
@@ -288,8 +288,8 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?)
|
||||
Query:
|
||||
INSERT INTO group_members
|
||||
( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by,
|
||||
user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
SEARCH rcv_roster_transfers USING COVERING INDEX idx_rcv_roster_transfers_from_member_id (from_member_id=?)
|
||||
@@ -395,8 +395,8 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta
|
||||
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, business_chat)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
created_at, updated_at, chat_ts, user_member_profile_sent_at, business_chat, member_priv_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
|
||||
@@ -1336,8 +1336,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)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
business_chat, business_member_id, customer_member_id, use_relays, relay_own_status, public_member_count, member_priv_key)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
|
||||
@@ -1381,7 +1381,7 @@ Query:
|
||||
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
|
||||
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status
|
||||
FROM chat_items i
|
||||
LEFT JOIN files f ON f.chat_item_id = i.chat_item_id
|
||||
WHERE i.user_id = ? AND i.note_folder_id = ? AND i.chat_item_id = ?
|
||||
@@ -1399,7 +1399,7 @@ Query:
|
||||
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
|
||||
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status,
|
||||
-- CIMeta forwardedByMember, showGroupAsSender
|
||||
i.forwarded_by_group_member_id, i.show_group_as_sender,
|
||||
-- GroupMember
|
||||
@@ -1456,7 +1456,7 @@ Query:
|
||||
i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id,
|
||||
i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed,
|
||||
-- CIFile
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at,
|
||||
f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, f.file_expires_at, f.file_max_size, f.file_badge_status,
|
||||
-- DirectQuote
|
||||
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
|
||||
FROM chat_items i
|
||||
@@ -1745,7 +1745,8 @@ 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, f.file_type, f.file_digest
|
||||
r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name, f.file_type, f.file_digest,
|
||||
f.file_max_size, f.file_badge_status
|
||||
FROM rcv_files r
|
||||
JOIN files f USING (file_id)
|
||||
LEFT JOIN contacts cs ON cs.contact_id = f.contact_id
|
||||
@@ -4053,6 +4054,14 @@ SEARCH pgm USING INDEX idx_pending_group_messages_group_member_id (group_member_
|
||||
SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
|
||||
USE TEMP B-TREE FOR ORDER BY
|
||||
|
||||
Query:
|
||||
SELECT proof_kind, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra
|
||||
FROM file_badge_proofs
|
||||
WHERE file_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH file_badge_proofs USING INDEX idx_file_badge_proofs_file_id_kind (file_id=?)
|
||||
|
||||
Query:
|
||||
SELECT r.contact_id, g.group_id, r.group_member_id
|
||||
FROM received_probes r
|
||||
@@ -4855,6 +4864,7 @@ Plan:
|
||||
SEARCH files USING INDEX idx_files_user_id (user_id=?)
|
||||
LIST SUBQUERY 1
|
||||
SEARCH chat_items USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (user_id=? AND note_folder_id=?)
|
||||
SEARCH file_badge_proofs USING COVERING INDEX idx_file_badge_proofs_file_id_kind (file_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=?)
|
||||
@@ -4991,6 +5001,20 @@ Query:
|
||||
|
||||
Plan:
|
||||
|
||||
Query:
|
||||
INSERT INTO file_badge_proofs (file_id, proof_kind, badge_proof, badge_pres_header, badge_key_idx, badge_type, badge_expiry, badge_extra, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (file_id, proof_kind) DO UPDATE SET
|
||||
badge_proof = excluded.badge_proof,
|
||||
badge_pres_header = excluded.badge_pres_header,
|
||||
badge_key_idx = excluded.badge_key_idx,
|
||||
badge_type = excluded.badge_type,
|
||||
badge_expiry = excluded.badge_expiry,
|
||||
badge_extra = excluded.badge_extra,
|
||||
updated_at = excluded.updated_at
|
||||
|
||||
Plan:
|
||||
|
||||
Query:
|
||||
INSERT INTO files
|
||||
( user_id, note_folder_id,
|
||||
@@ -6802,6 +6826,7 @@ 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 file_badge_proofs USING COVERING INDEX idx_file_badge_proofs_file_id_kind (file_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=?)
|
||||
@@ -6810,6 +6835,7 @@ 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=?)
|
||||
SEARCH file_badge_proofs USING COVERING INDEX idx_file_badge_proofs_file_id_kind (file_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=?)
|
||||
@@ -6818,6 +6844,7 @@ 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 file_badge_proofs USING COVERING INDEX idx_file_badge_proofs_file_id_kind (file_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=?)
|
||||
@@ -7128,13 +7155,13 @@ Plan:
|
||||
Query: INSERT INTO files (contact_id, group_id, user_id, file_name, file_path, file_crypto_key, file_crypto_nonce, file_size, chunk_size, redirect_file_id, agent_snd_file_id, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
Query: INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_max_size, file_badge_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: INSERT INTO files (user_id, file_name, file_path, file_size, chunk_size, ci_file_status, protocol, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: 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, file_digest) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
Query: 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, file_digest, file_max_size, file_badge_status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
Plan:
|
||||
|
||||
Query: 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 (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
@@ -303,7 +303,9 @@ CREATE TABLE files(
|
||||
file_type TEXT NOT NULL DEFAULT 'normal',
|
||||
roster_transfer_id INTEGER,
|
||||
file_digest BLOB,
|
||||
file_expires_at TEXT
|
||||
file_expires_at TEXT,
|
||||
file_max_size INTEGER,
|
||||
file_badge_status TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE snd_files(
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
@@ -853,6 +855,19 @@ CREATE TABLE rcv_roster_transfers(
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
) STRICT;
|
||||
CREATE TABLE file_badge_proofs(
|
||||
badge_proof_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE,
|
||||
proof_kind TEXT NOT NULL,
|
||||
badge_proof BLOB NOT NULL,
|
||||
badge_pres_header BLOB NOT NULL,
|
||||
badge_key_idx INTEGER NOT NULL,
|
||||
badge_type TEXT NOT NULL,
|
||||
badge_expiry TEXT NOT NULL,
|
||||
badge_extra TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE invoices(
|
||||
invoice_id TEXT NOT NULL PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
@@ -1535,6 +1550,10 @@ CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id);
|
||||
CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items(
|
||||
item_signed_by_group_member_id
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_file_badge_proofs_file_id_kind ON file_badge_proofs(
|
||||
file_id,
|
||||
proof_kind
|
||||
);
|
||||
CREATE INDEX idx_payments_provider_ref ON payments(provider, provider_ref);
|
||||
CREATE INDEX idx_payments_invoice ON payments(invoice_id);
|
||||
CREATE INDEX idx_badge_offers_price ON badge_offers(price_id);
|
||||
|
||||
@@ -734,10 +734,12 @@ toPublicGroupAccess (groupWebPage, groupDomain_, domainWebPage_, allowEmbedding_
|
||||
allowEmbedding = maybe False unBI allowEmbedding_
|
||||
|
||||
toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys
|
||||
toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) =
|
||||
(\grk -> GroupKeys {publicGroupId, groupRootKey = grk, memberPrivKey})
|
||||
<$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_)
|
||||
toGroupKeys _ _ = Nothing
|
||||
toGroupKeys publicGroupId_ (rootPrivKey, rootPubKey, memberPrivKey) =
|
||||
let publicGroupKeys = case (publicGroupId_, GRKPrivate <$> rootPrivKey <|> GRKPublic <$> rootPubKey) of
|
||||
(Just publicGroupId, Just groupRootKey) -> Just $ Just PublicGroupKeys {publicGroupId, groupRootKey}
|
||||
(Nothing, Nothing) -> Just Nothing
|
||||
_ -> Nothing -- invalid state, in which case messages won't be signed even if memberPrivKey is present
|
||||
in GroupKeys <$> publicGroupKeys <*> memberPrivKey
|
||||
|
||||
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, memberCode_, memberCodeVerifiedAt_)) =
|
||||
|
||||
@@ -80,7 +80,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do
|
||||
CRChatItemUpdated u (AChatItem _ SMDSnd cInfo _) -> whenCurrUser cc u $ setActiveChat ct cInfo
|
||||
CRChatItemsDeleted u ((ChatItemDeletion (AChatItem _ _ cInfo _) _) : _) _ _ -> whenCurrUser cc u $ setActiveChat ct cInfo
|
||||
CRContactDeleted u c -> whenCurrUser cc u $ unsetActiveContact ct c
|
||||
CRGroupDeletedUser u g _ -> whenCurrUser cc u $ unsetActiveGroup ct g
|
||||
CRGroupDeletedUser u g _ _ -> whenCurrUser cc u $ unsetActiveGroup ct g
|
||||
CRSentGroupInvitation u g _ _ -> whenCurrUser cc u $ setActiveGroup ct g
|
||||
CRCmdOk _ -> case cmd of
|
||||
Right APIDeleteUser {} -> setActive ct ""
|
||||
|
||||
@@ -480,12 +480,17 @@ groupRootPubKey (GRKPrivate pk) = C.publicKey pk
|
||||
groupRootPubKey (GRKPublic pk) = pk
|
||||
|
||||
data GroupKeys = GroupKeys
|
||||
{ publicGroupId :: B64UrlByteString,
|
||||
groupRootKey :: GroupRootKey,
|
||||
{ publicGroupKeys :: Maybe PublicGroupKeys,
|
||||
memberPrivKey :: C.PrivateKeyEd25519
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data PublicGroupKeys = PublicGroupKeys
|
||||
{ publicGroupId :: B64UrlByteString,
|
||||
groupRootKey :: GroupRootKey
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data GroupInfo = GroupInfo
|
||||
{ groupId :: GroupId,
|
||||
useRelays :: BoolDef,
|
||||
@@ -943,6 +948,7 @@ instance ToJSON GroupLinkId where
|
||||
|
||||
data GroupInvitation = GroupInvitation
|
||||
{ fromMember :: MemberIdRole,
|
||||
fromMemberKey :: Maybe MemberKey,
|
||||
invitedMember :: MemberIdRole,
|
||||
connRequest :: ConnReqInvitation,
|
||||
groupProfile :: GroupProfile,
|
||||
@@ -955,6 +961,7 @@ data GroupInvitation = GroupInvitation
|
||||
data GroupLinkInvitation = GroupLinkInvitation
|
||||
{ fromMember :: MemberIdRole,
|
||||
fromMemberName :: ContactName,
|
||||
fromMemberKey :: Maybe MemberKey,
|
||||
invitedMember :: MemberIdRole,
|
||||
groupProfile :: GroupProfile,
|
||||
accepted :: Maybe GroupAcceptance,
|
||||
@@ -1551,7 +1558,8 @@ data FileInvitation = FileInvitation
|
||||
fileDigest :: Maybe FileDigest,
|
||||
fileConnReq :: Maybe ConnReqInvitation,
|
||||
fileInline :: Maybe InlineFileMode,
|
||||
fileDescr :: Maybe FileDescr
|
||||
fileDescr :: Maybe FileDescr,
|
||||
fileBadge :: Maybe BadgeProof
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -1566,7 +1574,8 @@ xftpFileInvitation fileName fileSize fileDescr =
|
||||
fileDigest = Nothing,
|
||||
fileConnReq = Nothing,
|
||||
fileInline = Nothing,
|
||||
fileDescr = Just fileDescr
|
||||
fileDescr = Just fileDescr,
|
||||
fileBadge = Nothing
|
||||
}
|
||||
|
||||
data InlineFileMode
|
||||
@@ -1620,10 +1629,14 @@ instance ToJSON FileType where
|
||||
toJSON = J.String . textEncode
|
||||
toEncoding = JE.text . textEncode
|
||||
|
||||
data FileProhibited = FileProhibited {maxSize :: Integer, badgeStatus :: Maybe BadgeStatus}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data RcvFileTransfer = RcvFileTransfer
|
||||
{ fileId :: FileTransferId,
|
||||
xftpRcvFile :: Maybe XFTPRcvFile,
|
||||
fileInvitation :: FileInvitation,
|
||||
fileProhibited :: Maybe FileProhibited,
|
||||
fileStatus :: RcvFileStatus,
|
||||
fileType :: FileType,
|
||||
rcvFileInline :: Maybe InlineFileMode,
|
||||
@@ -2336,6 +2349,8 @@ instance FromJSON GroupSummary where
|
||||
|
||||
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GRK") ''GroupRootKey)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''PublicGroupKeys)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''GroupKeys)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''GroupInfo)
|
||||
@@ -2370,6 +2385,8 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberRef)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''FileDescr)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''FileProhibited)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''FileInvitation)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''SndFileTransfer)
|
||||
|
||||
@@ -245,7 +245,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
|
||||
"use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group (also clears the rejection)"
|
||||
]
|
||||
| otherwise -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g
|
||||
CRGroupDeletedUser u g signed -> ttyUser u [ttyGroup' g <> ": you deleted the group" <> signedStr signed]
|
||||
CRGroupDeletedUser u g signed local -> ttyUser u [ttyGroup' g <> (if local then ": you deleted your local copy of the group" else ": you deleted the group" <> signedStr signed)]
|
||||
CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc
|
||||
CRChatMsgContent u mc -> ttyUser u $ ttyMsgContent mc <> viewMsgTestInfo testView mc
|
||||
CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
|
||||
@@ -2480,11 +2480,25 @@ viewReceivedFileInvitation :: StyledString -> CIFile d -> CurrentTime -> TimeZon
|
||||
viewReceivedFileInvitation from file ts tz meta = receivedWithTime_ ts tz from [] meta (receivedFileInvitation_ file) False
|
||||
|
||||
receivedFileInvitation_ :: CIFile d -> [StyledString]
|
||||
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus} =
|
||||
receivedFileInvitation_ CIFile {fileId, fileName, fileSize, fileStatus, fileProhibited} =
|
||||
["sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)"]
|
||||
<> case fileStatus of
|
||||
CIFSRcvAccepted -> []
|
||||
_ -> ["use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"]
|
||||
<> case fileProhibited of
|
||||
Just fp -> [prohibitedFileReason fp]
|
||||
Nothing -> case fileStatus of
|
||||
CIFSRcvAccepted -> []
|
||||
_ -> ["use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"]
|
||||
|
||||
prohibitedFileReason :: FileProhibited -> StyledString
|
||||
prohibitedFileReason FileProhibited {maxSize, badgeStatus} =
|
||||
"file is above the limit of " <> sShow maxSize <> " bytes: " <> reason
|
||||
where
|
||||
reason = case badgeStatus of
|
||||
Nothing -> "sender has no badge"
|
||||
Just BSActive -> "above the limit of the sender badge"
|
||||
Just BSExpired -> "sender badge expired"
|
||||
Just BSExpiredOld -> "sender badge expired"
|
||||
Just BSFailed -> "sender badge did not verify"
|
||||
Just BSUnknownKey -> "sender badge key is not known"
|
||||
|
||||
humanReadableSize :: Integer -> StyledString
|
||||
humanReadableSize size
|
||||
|
||||
@@ -24,7 +24,7 @@ module Simplex.Chat.Web
|
||||
where
|
||||
|
||||
import Control.Concurrent.STM (check, flushTQueue)
|
||||
import Control.Exception (SomeException, catch)
|
||||
import Control.Exception (SomeException)
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad
|
||||
import Control.Monad.Except (runExceptT)
|
||||
@@ -75,7 +75,7 @@ import Simplex.Chat.Types
|
||||
)
|
||||
import Simplex.Messaging.Agent.Store.Common (withTransaction)
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Util (catchOwn, eitherToMaybe, safeDecodeUtf8, tshow)
|
||||
import Simplex.Messaging.Util (catchOwn, catchOwn', eitherToMaybe, safeDecodeUtf8, tshow)
|
||||
import Simplex.Messaging.Parsers (defaultJSON)
|
||||
import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile)
|
||||
import System.FilePath (dropExtension, takeExtension, (</>))
|
||||
@@ -150,7 +150,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva
|
||||
drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case
|
||||
Nothing -> pure ()
|
||||
Just f -> do
|
||||
removeFile (webJsonDir </> f) `catch` \(_ :: SomeException) -> pure ()
|
||||
removeFile (webJsonDir </> f) `catchOwn'` \(_ :: SomeException) -> pure ()
|
||||
drainRemovals
|
||||
|
||||
-- flush the whole queue and render each group once: a burst of changes in one
|
||||
@@ -202,7 +202,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva
|
||||
renderOneGroup WebPreviewState {publishableGroupIds} gId = do
|
||||
publishable <- atomically $ M.member gId <$> readTVar publishableGroupIds
|
||||
when publishable $
|
||||
renderOrRemoveStale `catch` \(e :: SomeException) ->
|
||||
renderOrRemoveStale `catchOwn'` \(e :: SomeException) ->
|
||||
logError $ "web preview: error rendering group " <> T.pack (show gId) <> ": " <> T.pack (show e)
|
||||
where
|
||||
renderOrRemoveStale = do
|
||||
@@ -217,7 +217,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva
|
||||
modifyTVar' publishableGroupIds (M.delete gId)
|
||||
pure $ pgFileName <$> pg
|
||||
forM_ fName $ \f ->
|
||||
removeFile (webJsonDir </> f) `catch` \(_ :: SomeException) -> pure ()
|
||||
removeFile (webJsonDir </> f) `catchOwn'` \(_ :: SomeException) -> pure ()
|
||||
logInfo $ "web preview: group " <> T.pack (show gId) <> " no longer publishable"
|
||||
|
||||
findUser f = go users
|
||||
|
||||
+35
-2
@@ -11,6 +11,7 @@ module BadgeTests (badgeTests) where
|
||||
|
||||
import BadgeService.Service (badgeErrorRetryAfter)
|
||||
import Control.Concurrent.STM (atomically)
|
||||
import Data.ByteString.Char8 (ByteString)
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import qualified Data.Text as T
|
||||
@@ -32,6 +33,7 @@ import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeRetryAfter, badgeSta
|
||||
import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..))
|
||||
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay)
|
||||
import Simplex.Messaging.Crypto.BBS
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import Simplex.Messaging.Protocol (BrokerErrorType (..), NetworkError (..))
|
||||
import Simplex.Messaging.Version.Internal (Version (..))
|
||||
import Test.Hspec
|
||||
@@ -46,6 +48,8 @@ badgeTests = do
|
||||
it "should compute badge status correctly" testExpiryCheck
|
||||
it "should accept unknown badge types" testUnknownBadgeType
|
||||
it "credential serializes to a paste-able token and back" testCredentialSerialization
|
||||
it "presentation headers encode and decode" testPresHeaderEncoding
|
||||
it "should reject a proof presented under another chat binding" testOtherChatBinding
|
||||
describe "redemption codes" $ do
|
||||
it "a generated code reads back" testCodeRoundTrip
|
||||
it "reads a code as typed - any case, separators, ambiguous characters" testCodeNormalisation
|
||||
@@ -186,14 +190,43 @@ futureTime = posixSecondsToUTCTime 4102444800 -- 2099-12-31
|
||||
pastTime :: UTCTime
|
||||
pastTime = posixSecondsToUTCTime 1577836800 -- 2020-01-01
|
||||
|
||||
testPresHeaderEncoding :: IO ()
|
||||
testPresHeaderEncoding =
|
||||
mapM_
|
||||
(\ph -> strDecode (strEncode ph) `shouldBe` Right ph)
|
||||
[ PHTest "nonce",
|
||||
PHChat aliceBinding,
|
||||
PHFileInv {chatBinding = aliceBinding, fileSize = 139737},
|
||||
PHFileDescr {chatBinding = aliceBinding, fileSize = 139737, descrHash = "descr-hash", fileExpires = Nothing},
|
||||
PHFileDescr {chatBinding = aliceBinding, fileSize = 139737, descrHash = "descr-hash", fileExpires = Just futureTime},
|
||||
PHUnknown 'Z' "payload"
|
||||
]
|
||||
|
||||
testOtherChatBinding :: IO ()
|
||||
testOtherChatBinding = do
|
||||
let ph = PHFileInv {chatBinding = aliceBinding, fileSize = 139737}
|
||||
otherPh = PHFileInv {chatBinding = bobBinding, fileSize = 139737}
|
||||
(pk, BadgeProof idx _ p info) <- issueBadgeProofHeader BTSupporter futureTime ph
|
||||
verifyBadge (keysFor pk) (BadgeProof idx (BBSPresHeader $ strEncode ph) p info) >>= (`shouldBe` Just True)
|
||||
verifyBadge (keysFor pk) (BadgeProof idx (BBSPresHeader $ strEncode otherPh) p info) >>= (`shouldBe` Just False)
|
||||
|
||||
aliceBinding :: ByteString
|
||||
aliceBinding = "Galice-member-id"
|
||||
|
||||
bobBinding :: ByteString
|
||||
bobBinding = "Gbob-member-id"
|
||||
|
||||
issueBadgeProof :: BadgeType -> UTCTime -> IO (BBSPublicKey, BadgeProof)
|
||||
issueBadgeProof bt expiry = do
|
||||
issueBadgeProof bt expiry = issueBadgeProofHeader bt expiry (PHTest "test-nonce")
|
||||
|
||||
issueBadgeProofHeader :: BadgeType -> UTCTime -> ProofPresHeader -> IO (BBSPublicKey, BadgeProof)
|
||||
issueBadgeProofHeader bt expiry ph = 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")
|
||||
Right badge <- badgeProof pk cred ph
|
||||
pure (pk, badge)
|
||||
|
||||
-- Redemption codes
|
||||
|
||||
@@ -78,6 +78,7 @@ directoryServiceTests = do
|
||||
describe "member admission" $ do
|
||||
it "should require captcha by default for new groups" testCaptchaByDefault
|
||||
it "should require captcha in all groups with --always-captcha" testAlwaysCaptcha
|
||||
it "should make joining members observers in all groups with --always-observer" testAlwaysObserver
|
||||
it "should require admin review in all groups with --knocking" testKnocking
|
||||
it "should ask member to pass captcha screen" testCapthaScreening
|
||||
it "should send voice captcha on /audio command" testVoiceCaptchaScreening
|
||||
@@ -144,6 +145,7 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder =
|
||||
linkCheckInterval = 0,
|
||||
prohibitedToObserver = False,
|
||||
alwaysCaptcha = False,
|
||||
alwaysObserver = False,
|
||||
knocking = False,
|
||||
testing = True
|
||||
}
|
||||
@@ -343,7 +345,7 @@ testDeleteGroupAdmin ps =
|
||||
submitGroup bob "security" "Security"
|
||||
bob <# "'SimpleX Directory'> The group security (Security) is already listed in the directory, please choose another name."
|
||||
bob ##> "/d #security"
|
||||
bob <## "#security: you deleted the group"
|
||||
bob <## "#security: you deleted the group (signed)"
|
||||
-- admin can delete the group
|
||||
superUser #> "@'SimpleX Directory' /delete 2:security"
|
||||
superUser <# "'SimpleX Directory'> > /delete 2:security"
|
||||
@@ -622,7 +624,7 @@ testInviteOwnerAfterLeavingOwnersGroup ps =
|
||||
superUser <## "#owners: new member bob is connected"
|
||||
-- owner leaves owners' group; GroupMember row keeps status GSMemLeft
|
||||
leaveGroup "owners" bob
|
||||
superUser <## "#owners: bob left the group"
|
||||
superUser <## "#owners: bob left the group (signed)"
|
||||
-- owners' group has no GroupReg, so directory service notifies admins on contact left
|
||||
superUser <# "'SimpleX Directory'> Error: contact left, group: 1 owners, group registration not found"
|
||||
-- super-user re-invites via /invite — must send a fresh invitation, not "already a member"
|
||||
@@ -641,7 +643,7 @@ testDelistedOwnerLeaves ps =
|
||||
registerGroup superUser bob "privacy" "Privacy"
|
||||
addCathAsOwner bob cath
|
||||
leaveGroup "privacy" bob
|
||||
cath <## "#privacy: bob left the group"
|
||||
cath <## "#privacy: bob left the group (signed)"
|
||||
bob <# "'SimpleX Directory'> You left the group ID 1 (privacy)."
|
||||
bob <## ""
|
||||
bob <## "The group is no longer listed in the directory."
|
||||
@@ -678,7 +680,7 @@ testNotDelistedMemberLeaves ps =
|
||||
registerGroup superUser bob "privacy" "Privacy"
|
||||
addCathAsOwner bob cath
|
||||
leaveGroup "privacy" cath
|
||||
bob <## "#privacy: cath left the group"
|
||||
bob <## "#privacy: cath left the group (signed)"
|
||||
(superUser </)
|
||||
cath `connectVia` dsLink
|
||||
cath #> "@'SimpleX Directory_1' privacy"
|
||||
@@ -743,7 +745,7 @@ testNotDelistedOwnerRejoinsViaLink ps =
|
||||
bob ##> "/l privacy_1"
|
||||
bob <## "#privacy_1: you left the group"
|
||||
bob <## "use /d #privacy_1 to delete the group"
|
||||
bob <## "#privacy: bob_1 left the group"
|
||||
bob <## "#privacy: bob_1 left the group (signed)"
|
||||
-- the group must remain listed: the leaving member is not the owner member
|
||||
(superUser </)
|
||||
groupFound bob "privacy"
|
||||
@@ -757,8 +759,8 @@ testDelistedServiceRemoved ps =
|
||||
registerGroup superUser bob "privacy" "Privacy"
|
||||
addCathAsOwner bob cath
|
||||
bob ##> "/rm #privacy 'SimpleX Directory'"
|
||||
bob <## "#privacy: you removed 'SimpleX Directory' from the group"
|
||||
cath <## "#privacy: bob removed 'SimpleX Directory' from the group"
|
||||
bob <## "#privacy: you removed 'SimpleX Directory' from the group (signed)"
|
||||
cath <## "#privacy: bob removed 'SimpleX Directory' from the group (signed)"
|
||||
bob <# "'SimpleX Directory'> SimpleX Directory is removed from the group ID 1 (privacy)."
|
||||
bob <## ""
|
||||
bob <## "The group is no longer listed in the directory."
|
||||
@@ -781,11 +783,11 @@ testDelistedGroupDeleted ps =
|
||||
cath <## "contact and member are merged: 'SimpleX Directory', #privacy 'SimpleX Directory_1'"
|
||||
cath <## "use @'SimpleX Directory' <message> to send messages"
|
||||
bob ##> "/d #privacy"
|
||||
bob <## "#privacy: you deleted the group"
|
||||
bob <## "#privacy: you deleted the group (signed)"
|
||||
bob <# "'SimpleX Directory'> The group ID 1 (privacy) is deleted."
|
||||
bob <## ""
|
||||
bob <## "The group is no longer listed in the directory."
|
||||
cath <## "#privacy: bob deleted the group"
|
||||
cath <## "#privacy: bob deleted the group (signed)"
|
||||
cath <## "use /d #privacy to delete the local copy of the group"
|
||||
superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is de-listed (group is deleted)."
|
||||
groupNotFound cath "privacy"
|
||||
@@ -804,8 +806,8 @@ testDelistedRoleChanges ps =
|
||||
groupFoundN 3 cath "privacy"
|
||||
-- de-listed if service role changed
|
||||
bob ##> "/mr privacy 'SimpleX Directory' member"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to member"
|
||||
cath <## "#privacy: bob changed the role of 'SimpleX Directory' from admin to member"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)"
|
||||
cath <## "#privacy: bob changed the role of 'SimpleX Directory' from admin to member (signed)"
|
||||
bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to member."
|
||||
bob <## ""
|
||||
bob <## "The group is no longer listed in the directory."
|
||||
@@ -813,8 +815,8 @@ testDelistedRoleChanges ps =
|
||||
groupNotFound cath "privacy"
|
||||
-- re-listed if service role changed back without profile changes
|
||||
cath ##> "/mr privacy 'SimpleX Directory' admin"
|
||||
cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin"
|
||||
bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin"
|
||||
cath <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)"
|
||||
bob <## "#privacy: cath changed the role of 'SimpleX Directory' from member to admin (signed)"
|
||||
bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
bob <## "The group is listed in the directory again."
|
||||
@@ -822,8 +824,8 @@ testDelistedRoleChanges ps =
|
||||
groupFoundN 3 cath "privacy"
|
||||
-- de-listed if owner role changed
|
||||
cath ##> "/mr privacy bob admin"
|
||||
cath <## "#privacy: you changed the role of bob to admin"
|
||||
bob <## "#privacy: cath changed your role from owner to admin"
|
||||
cath <## "#privacy: you changed the role of bob to admin (signed)"
|
||||
bob <## "#privacy: cath changed your role from owner to admin (signed)"
|
||||
bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
bob <## "The group is no longer listed in the directory."
|
||||
@@ -831,8 +833,8 @@ testDelistedRoleChanges ps =
|
||||
groupNotFound cath "privacy"
|
||||
-- re-listed if owner role changed back without profile changes
|
||||
cath ##> "/mr privacy bob owner"
|
||||
cath <## "#privacy: you changed the role of bob to owner"
|
||||
bob <## "#privacy: cath changed your role from admin to owner"
|
||||
cath <## "#privacy: you changed the role of bob to owner (signed)"
|
||||
bob <## "#privacy: cath changed your role from admin to owner (signed)"
|
||||
bob <# "'SimpleX Directory'> Your role in the group ID 1 (privacy) is changed to owner."
|
||||
bob <## ""
|
||||
bob <## "The group is listed in the directory again."
|
||||
@@ -852,8 +854,8 @@ testNotDelistedMemberRoleChanged ps =
|
||||
cath <## "use @'SimpleX Directory' <message> to send messages"
|
||||
groupFoundN 3 cath "privacy"
|
||||
bob ##> "/mr privacy cath member"
|
||||
bob <## "#privacy: you changed the role of cath to member"
|
||||
cath <## "#privacy: bob changed your role from owner to member"
|
||||
bob <## "#privacy: you changed the role of cath to member (signed)"
|
||||
cath <## "#privacy: bob changed your role from owner to member (signed)"
|
||||
groupFoundN 3 cath "privacy"
|
||||
|
||||
testNotSentApprovalBadRoles :: HasCallStack => TestParams -> IO ()
|
||||
@@ -867,13 +869,13 @@ testNotSentApprovalBadRoles ps =
|
||||
groupAccepted bob "privacy" 1
|
||||
notifySuperUser superUser bob "privacy" "Privacy" 1
|
||||
bob ##> "/mr privacy 'SimpleX Directory' member"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to member"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)"
|
||||
bob ##> "/gp privacy privacy Privacy!"
|
||||
bob <## "description changed to: Privacy!"
|
||||
groupUpdatedHidden superUser bob "privacy" ""
|
||||
bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group"
|
||||
bob ##> "/mr privacy 'SimpleX Directory' admin"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)"
|
||||
bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
bob <## "The group is submitted for approval."
|
||||
@@ -893,14 +895,14 @@ testNotApprovedBadRoles ps =
|
||||
groupAccepted bob "privacy" 1
|
||||
notifySuperUser superUser bob "privacy" "Privacy" 1
|
||||
bob ##> "/mr privacy 'SimpleX Directory' member"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to member"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to member (signed)"
|
||||
let approve = "/approve 1:privacy 1"
|
||||
superUser #> ("@'SimpleX Directory' " <> approve)
|
||||
superUser <# ("'SimpleX Directory'> > " <> approve)
|
||||
superUser <## " Group is not approved: SimpleX Directory is not an admin."
|
||||
groupNotFound cath "privacy"
|
||||
bob ##> "/mr privacy 'SimpleX Directory' admin"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin"
|
||||
bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin (signed)"
|
||||
bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin."
|
||||
bob <## ""
|
||||
bob <## "The group is submitted for approval."
|
||||
@@ -920,7 +922,7 @@ testRegOwnerChangedProfile ps =
|
||||
bob <## "description changed to: Privacy and Security"
|
||||
bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!"
|
||||
bob <## "It is hidden from the directory until approved."
|
||||
cath <## "bob updated group #privacy:"
|
||||
cath <## "bob updated group #privacy: (signed)"
|
||||
cath <## "description changed to: Privacy and Security"
|
||||
cath `connectVia` dsLink
|
||||
cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'"
|
||||
@@ -943,7 +945,7 @@ testAnotherOwnerChangedProfile ps =
|
||||
cath <## "use @'SimpleX Directory' <message> to send messages"
|
||||
cath ##> "/gp privacy privacy Privacy and Security"
|
||||
cath <## "description changed to: Privacy and Security"
|
||||
bob <## "cath updated group #privacy:"
|
||||
bob <## "cath updated group #privacy: (signed)"
|
||||
bob <## "description changed to: Privacy and Security"
|
||||
bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!"
|
||||
bob <## "It is hidden from the directory until approved."
|
||||
@@ -964,7 +966,7 @@ testNotConnectedOwnerChangedProfile ps =
|
||||
addCathAsOwner bob cath
|
||||
cath ##> "/gp privacy privacy Privacy and Security"
|
||||
cath <## "description changed to: Privacy and Security"
|
||||
bob <## "cath updated group #privacy:"
|
||||
bob <## "cath updated group #privacy: (signed)"
|
||||
bob <## "description changed to: Privacy and Security"
|
||||
bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated by cath!"
|
||||
bob <## "It is hidden from the directory until approved."
|
||||
@@ -1176,7 +1178,7 @@ testListUserGroups promote ps =
|
||||
-- with de-listed group
|
||||
groupFound cath "anonymity"
|
||||
cath ##> "/mr anonymity 'SimpleX Directory' member"
|
||||
cath <## "#anonymity: you changed the role of 'SimpleX Directory' to member"
|
||||
cath <## "#anonymity: you changed the role of 'SimpleX Directory' to member (signed)"
|
||||
cath <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (anonymity) is changed to member."
|
||||
cath <## ""
|
||||
cath <## "The group is no longer listed in the directory."
|
||||
@@ -1191,7 +1193,7 @@ testListUserGroups promote ps =
|
||||
checkListings ["privacy", "security"] ["privacy"]
|
||||
bob ##> "/gp privacy privacy"
|
||||
bob <## "description removed"
|
||||
cath <## "bob updated group #privacy:"
|
||||
cath <## "bob updated group #privacy: (signed)"
|
||||
cath <## "description removed"
|
||||
groupUpdatedHidden superUser bob "privacy" ""
|
||||
superUser <# "'SimpleX Directory'> bob submitted the group ID 1:"
|
||||
@@ -1258,6 +1260,34 @@ testAlwaysCaptcha ps =
|
||||
bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)"
|
||||
bob <## "#privacy: new member cath is connected"
|
||||
|
||||
testAlwaysObserver :: HasCallStack => TestParams -> IO ()
|
||||
testAlwaysObserver ps =
|
||||
withDirectoryServiceOpts ps (\o -> o {alwaysObserver = True}) $ \superUser dsLink ->
|
||||
withNewTestChat ps "bob" bobProfile $ \bob ->
|
||||
withNewTestChat ps "cath" cathProfile $ \cath -> do
|
||||
bob `connectVia` dsLink
|
||||
submitGroup bob "privacy" "Privacy"
|
||||
groupAccepted bob "privacy" 1
|
||||
welcomeWithLink <- completeRegistration superUser bob "privacy" "Privacy" 1
|
||||
let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeWithLink
|
||||
cath ##> ("/c " <> groupLink)
|
||||
cath <## "connection request sent!"
|
||||
cath <## "#privacy: joining the group..."
|
||||
cath <## "#privacy: you joined the group, pending approval"
|
||||
cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service."
|
||||
cath <## ""
|
||||
cath <## "Send captcha text to join the group privacy."
|
||||
captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath
|
||||
cath #> ("#privacy (support) " <> captcha)
|
||||
cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha)
|
||||
cath <## " Correct, you joined the group privacy"
|
||||
cath <## "#privacy: you joined the group"
|
||||
cath <## "#privacy: member bob (Bob) is connected"
|
||||
bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)"
|
||||
bob <## "#privacy: new member cath is connected"
|
||||
cath ##> "#privacy hello"
|
||||
cath <## "#privacy: you don't have permission to send messages"
|
||||
|
||||
testKnocking :: HasCallStack => TestParams -> IO ()
|
||||
testKnocking ps =
|
||||
withDirectoryServiceOpts ps (\o -> o {knocking = True}) $ \superUser dsLink ->
|
||||
@@ -1342,9 +1372,9 @@ testCapthaScreening ps =
|
||||
cath ##> "/l privacy"
|
||||
cath <## "#privacy: you left the group"
|
||||
cath <## "use /d #privacy to delete the group"
|
||||
bob <## "#privacy: cath left the group"
|
||||
bob <## "#privacy: cath left the group (signed)"
|
||||
cath ##> "/d #privacy"
|
||||
cath <## "#privacy: you deleted the group"
|
||||
cath <## "#privacy: you deleted your local copy of the group"
|
||||
-- change default role to observer
|
||||
bob #> "@'SimpleX Directory' /role 1 observer"
|
||||
bob <# "'SimpleX Directory'> > /role 1 observer"
|
||||
@@ -1888,7 +1918,7 @@ setWelcomeMessage u others welcome = do
|
||||
u <## "welcome message changed to:"
|
||||
u <## welcome
|
||||
forM_ others $ \m -> do
|
||||
m <## (uName <> " updated group #privacy:")
|
||||
m <## (uName <> " updated group #privacy: (signed)")
|
||||
m <## "welcome message changed to:"
|
||||
m <## welcome
|
||||
|
||||
@@ -1926,8 +1956,8 @@ removeMember gName admin removed = do
|
||||
adminName <- userName admin
|
||||
removedName <- userName removed
|
||||
admin ##> ("/rm " <> gName <> " " <> removedName)
|
||||
admin <## (gn <> ": you removed " <> removedName <> " from the group")
|
||||
removed <## (gn <> ": " <> adminName <> " removed you from the group")
|
||||
admin <## (gn <> ": you removed " <> removedName <> " from the group (signed)")
|
||||
removed <## (gn <> ": " <> adminName <> " removed you from the group (signed)")
|
||||
removed <## ("use /d " <> gn <> " to delete the group")
|
||||
|
||||
groupFound :: TestCC -> String -> IO ()
|
||||
|
||||
@@ -157,6 +157,7 @@ testCoreOpts =
|
||||
logAgent = Nothing,
|
||||
logFile = Nothing,
|
||||
tbqSize = 16,
|
||||
maxChats = 5000,
|
||||
deviceName = Nothing,
|
||||
chatRelay = False,
|
||||
webPreviewConfig = Nothing,
|
||||
|
||||
@@ -5,11 +5,13 @@ import ChatTests.DBUtils
|
||||
import ChatTests.Utils
|
||||
import Data.Time.Clock (getCurrentTime)
|
||||
import Data.Time.Format.ISO8601 (iso8601Show)
|
||||
import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..))
|
||||
import Test.Hspec hiding (it)
|
||||
|
||||
chatListTests :: SpecWith TestParams
|
||||
chatListTests = do
|
||||
it "get last chats" testPaginationLast
|
||||
it "get last chats with max chats option" testMaxChats
|
||||
it "get chats before/after timestamp" testPaginationTs
|
||||
it "filter by search query" testFilterSearch
|
||||
it "filter favorite" testFilterFavorite
|
||||
@@ -33,6 +35,23 @@ testPaginationLast =
|
||||
alice <# "bob> hey"
|
||||
alice <# "@cath hey"
|
||||
|
||||
testMaxChats :: HasCallStack => TestParams -> IO ()
|
||||
testMaxChats =
|
||||
testChatOpts3 testOpts {coreOptions = testCoreOpts {maxChats = 1}} aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
connectUsers alice bob
|
||||
alice <##> bob
|
||||
connectUsers alice cath
|
||||
cath <##> alice
|
||||
|
||||
alice ##> "/chats all"
|
||||
alice <# "@cath hey"
|
||||
alice ##> "/chats 2"
|
||||
alice <# "bob> hey"
|
||||
alice <# "@cath hey"
|
||||
alice #$> ("/_get chats 1 pcc=on", chats, [("@cath", "hey")])
|
||||
getChats_ alice "count=2" [("@cath", "hey"), ("@bob", "hey")]
|
||||
|
||||
testPaginationTs :: HasCallStack => TestParams -> IO ()
|
||||
testPaginationTs =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
|
||||
@@ -15,18 +15,21 @@ import ChatClient
|
||||
import ChatTests.DBUtils
|
||||
import ChatTests.Utils
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Concurrent.Async (concurrently_)
|
||||
import Control.Monad (forM_, void)
|
||||
import Control.Concurrent.Async (concurrently_, poll)
|
||||
import Control.Monad (forM_, void, (>=>))
|
||||
import Data.Aeson (ToJSON)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.List (intercalate, stripPrefix)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Maybe (isJust, isNothing)
|
||||
import qualified Data.Text as T
|
||||
import GHC.Conc (ThreadStatus (..), threadStatus)
|
||||
import Simplex.Chat.AppSettings (defaultAppSettings)
|
||||
import qualified Simplex.Chat.AppSettings as AS
|
||||
import Simplex.Chat.Call
|
||||
import Simplex.Chat.Controller (ChatConfig (..), PresetServers (..))
|
||||
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), PresetServers (..))
|
||||
import Simplex.Chat.Messages (ChatItemId)
|
||||
import Simplex.Chat.Options
|
||||
import Simplex.Chat.Protocol (supportedChatVRange)
|
||||
@@ -35,7 +38,7 @@ import Simplex.Messaging.Agent.Env.SQLite
|
||||
import Simplex.Messaging.Agent.RetryInterval
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
import Simplex.Messaging.Client (NetworkTimeout (..))
|
||||
import Control.Concurrent.STM (atomically)
|
||||
import Control.Concurrent.STM (atomically, readTVarIO)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Encoding.String (strEncode)
|
||||
import Simplex.Messaging.Server.Env.STM hiding (subscriptions)
|
||||
@@ -43,6 +46,7 @@ import Simplex.Messaging.Transport
|
||||
import Simplex.Messaging.Util (safeDecodeUtf8)
|
||||
import Simplex.Messaging.Version
|
||||
import System.Directory (copyFile, doesDirectoryExist, doesFileExist)
|
||||
import System.Mem.Weak (deRefWeak)
|
||||
import Test.Hspec hiding (it)
|
||||
#if defined(dbPostgres)
|
||||
import Database.PostgreSQL.Simple (Only (..))
|
||||
@@ -101,8 +105,9 @@ chatDirectTests = do
|
||||
it "connect, fully asynchronous (when clients are never simultaneously online)" $ testFullAsyncFast
|
||||
describe "webrtc calls api" $ do
|
||||
it "negotiate call" testNegotiateCall
|
||||
#if !defined(dbPostgres)
|
||||
describe "maintenance mode" $ do
|
||||
it "stop chat stops all threads, start chat restarts them" testStopStartChat
|
||||
#if !defined(dbPostgres)
|
||||
it "start/stop/export/import chat" testMaintenanceMode
|
||||
it "export/import chat with files" testMaintenanceModeWithFiles
|
||||
it "encrypt/decrypt database" testDatabaseEncryption
|
||||
@@ -1358,6 +1363,52 @@ testNegotiateCall =
|
||||
alice <## "call with bob ended"
|
||||
alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "outgoing call: ended (00:00)")])
|
||||
|
||||
testStopStartChat :: HasCallStack => TestParams -> IO ()
|
||||
testStopStartChat ps =
|
||||
withNewTestChat ps "bob" bobProfile $ \bob ->
|
||||
withNewTestChatCfg ps cfg "alice" aliceProfile $ \alice -> do
|
||||
connectUsers alice bob
|
||||
alice #> "@bob hi"
|
||||
bob <# "alice> hi"
|
||||
alice #$> ("/_ttl 1 4", id, "ok")
|
||||
alice ##> "/_set prefs @2 {\"timedMessages\": {\"allow\": \"yes\", \"ttl\": 2}}"
|
||||
alice <## "you updated preferences for bob:"
|
||||
alice <## "Disappearing messages: enabled (you allow: yes (2 sec), contact allows: yes)"
|
||||
bob <## "alice updated preferences for you:"
|
||||
bob <## "Disappearing messages: enabled (you allow: yes (2 sec), contact allows: yes (2 sec))"
|
||||
alice #> "@bob hi timed"
|
||||
bob <# "alice> hi timed"
|
||||
let ChatController {agentAsync, cleanupManagerAsync, expireCIThreads, timedItemThreads} = chatController alice
|
||||
Just (a1, Just a2) <- readTVarIO agentAsync
|
||||
Just cleanupA <- readTVarIO cleanupManagerAsync
|
||||
[Just expireA] <- M.elems <$> readTVarIO expireCIThreads
|
||||
[Just timedTId] <- mapM (readTVarIO >=> maybe (pure Nothing) deRefWeak) . M.elems =<< readTVarIO timedItemThreads
|
||||
alice ##> "/_stop"
|
||||
alice <## "chat stopped"
|
||||
forM_ [a1, a2, cleanupA, expireA] $ \a -> isJust <$> poll a `shouldReturn` True
|
||||
threadDelay 100000
|
||||
threadStatus timedTId `shouldReturn` ThreadFinished
|
||||
isNothing <$> readTVarIO agentAsync `shouldReturn` True
|
||||
isNothing <$> readTVarIO cleanupManagerAsync `shouldReturn` True
|
||||
M.null <$> readTVarIO expireCIThreads `shouldReturn` True
|
||||
M.null <$> readTVarIO timedItemThreads `shouldReturn` True
|
||||
alice ##> "/_start"
|
||||
alice <## "chat started"
|
||||
alice <## "subscribed 1 connections on server localhost"
|
||||
bob #> "@alice hello"
|
||||
alice <# "bob> hello"
|
||||
alice <### ["timed message deleted: hi timed", "timed message deleted: hello"]
|
||||
bob <### ["timed message deleted: hi timed", "timed message deleted: hello"]
|
||||
threadDelay 3000000
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "chat banner")])
|
||||
Just (a1', _) <- readTVarIO agentAsync
|
||||
(a1' == a1) `shouldBe` False
|
||||
Just cleanupA' <- readTVarIO cleanupManagerAsync
|
||||
(cleanupA' == cleanupA) `shouldBe` False
|
||||
M.keys <$> readTVarIO expireCIThreads `shouldReturn` [1]
|
||||
where
|
||||
cfg = testCfg {initialCleanupManagerDelay = 0, cleanupManagerStepDelay = 0, ciExpirationInterval = 500000}
|
||||
|
||||
testMaintenanceMode :: HasCallStack => TestParams -> IO ()
|
||||
testMaintenanceMode ps = do
|
||||
withNewTestChat ps "bob" bobProfile $ \bob -> do
|
||||
|
||||
+191
-3
@@ -7,19 +7,25 @@ module ChatTests.Files where
|
||||
|
||||
import ChatClient
|
||||
import ChatTests.DBUtils
|
||||
import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, issueTestBadgeType, testBadgeKeys)
|
||||
import ChatTests.Utils
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Concurrent.Async (concurrently_)
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad.Except (runExceptT)
|
||||
import Control.Monad.Reader (runReaderT)
|
||||
import qualified Data.Aeson as J
|
||||
import qualified Data.ByteString.Char8 as B
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Network.HTTP.Types.URI (urlEncode)
|
||||
import Data.Time.Clock (addUTCTime, getCurrentTime, nominalDay)
|
||||
import Simplex.Chat.Badges (BadgeProof, BadgeStatus (..), BadgeType (..), FileSizeLimits (..), ProofPresHeader (..), badgeProof, defaultFileSizeLimits)
|
||||
import Simplex.Chat.Controller (ChatConfig (..))
|
||||
import Simplex.Chat.Library.Internal (roundedFDCount)
|
||||
import Simplex.Chat.Library.Internal (badgeProofStatus, roundedFDCount)
|
||||
import Simplex.Chat.Mobile.File
|
||||
import Simplex.Chat.Options (ChatOpts (..))
|
||||
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), XFTPStoreConfig (..))
|
||||
import Simplex.Messaging.Crypto.BBS (BBSPublicKey, bbsKeyGen)
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
||||
import Simplex.Messaging.Encoding.String
|
||||
import System.Directory (copyFile, createDirectoryIfMissing, doesFileExist, getFileSize)
|
||||
@@ -63,6 +69,14 @@ chatFileTests = do
|
||||
it "send and receive large standalone file using relative paths" testXFTPStandaloneRelativePaths
|
||||
xit "removes sent file from server" testXFTPStandaloneCancelSnd -- no error shown in tests
|
||||
it "removes received temporary files" testXFTPStandaloneCancelRcv
|
||||
describe "send larger files with badges" $ do
|
||||
it "send and receive file with badge proof" testXFTPFileBadgeProof
|
||||
it "send and receive file with badge proof in group" testXFTPGroupFileBadgeProof
|
||||
it "file above the limit without badge proof is not accepted" testXFTPFileNoBadgeProof
|
||||
it "file above the limit the badge allows is not accepted" testXFTPFileBadgeAboveLimit
|
||||
it "sending file above the limit the badge allows fails" testXFTPSndFileBadgeLimit
|
||||
it "sending file with a badge expired past the send grace fails" testXFTPSndFileBadgeGrace
|
||||
it "file proof is rejected under another binding, size or expired badge" testFileBadgeProofStatus
|
||||
|
||||
runTestMessageWithFile :: HasCallStack => TestParams -> IO ()
|
||||
runTestMessageWithFile = testChat2 aliceProfile bobProfile $ \alice bob -> withXFTPServer $ do
|
||||
@@ -750,6 +764,180 @@ testXFTPGroupFileTransfer =
|
||||
dest1 `shouldBe` src
|
||||
dest2 `shouldBe` src
|
||||
|
||||
badgeFileCfg :: BBSPublicKey -> ChatConfig
|
||||
badgeFileCfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 300000, legend = 400000}
|
||||
|
||||
badgeFileCfgLimits :: BBSPublicKey -> FileSizeLimits -> ChatConfig
|
||||
badgeFileCfgLimits pk lims = testCfg {badgePublicKeys = testBadgeKeys pk, fileSizeLimits = lims}
|
||||
|
||||
testXFTPFileBadgeProof :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPFileBadgeProof ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
testChatCfg2 (badgeFileCfg pk) aliceProfile bobProfile (test sk) ps
|
||||
where
|
||||
test sk alice bob = withXFTPServer $ do
|
||||
connectUsers alice bob
|
||||
addTestBadge alice =<< issueTestBadge sk futureDate
|
||||
|
||||
alice #> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
bob ##> "/fr 1 ./tests/tmp"
|
||||
concurrentlyN_
|
||||
[ alice <## "completed uploading file 1 (test.pdf) for bob",
|
||||
bob
|
||||
<### [ "saving file 1 from alice to ./tests/tmp/test.pdf",
|
||||
"started receiving file 1 (test.pdf) from alice"
|
||||
]
|
||||
]
|
||||
bob <## "completed receiving file 1 (test.pdf) from alice"
|
||||
|
||||
src <- B.readFile "./tests/fixtures/test.pdf"
|
||||
dest <- B.readFile "./tests/tmp/test.pdf"
|
||||
dest `shouldBe` src
|
||||
|
||||
testXFTPGroupFileBadgeProof :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPGroupFileBadgeProof ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
testChatCfg3 (badgeFileCfg pk) aliceProfile bobProfile cathProfile (test sk) ps
|
||||
where
|
||||
test sk alice bob cath = withXFTPServer $ do
|
||||
createGroup3 "team" alice bob cath
|
||||
addTestBadge alice =<< issueTestBadge sk futureDate
|
||||
|
||||
alice #> "/f #team ./tests/fixtures/test.pdf"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "use /fr 1 [<dir>/ | <path>] to receive it",
|
||||
do
|
||||
cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
cath <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
]
|
||||
alice <## "completed uploading file 1 (test.pdf) for #team"
|
||||
|
||||
bob ##> "/fr 1 ./tests/tmp"
|
||||
bob
|
||||
<### [ "saving file 1 from alice to ./tests/tmp/test.pdf",
|
||||
"started receiving file 1 (test.pdf) from alice"
|
||||
]
|
||||
bob <## "completed receiving file 1 (test.pdf) from alice"
|
||||
|
||||
src <- B.readFile "./tests/fixtures/test.pdf"
|
||||
dest <- B.readFile "./tests/tmp/test.pdf"
|
||||
dest `shouldBe` src
|
||||
|
||||
testXFTPFileNoBadgeProof :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPFileNoBadgeProof ps =
|
||||
withNewTestChatCfg ps sndCfg "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatCfg ps rcvCfg "bob" bobProfile $ \bob -> withXFTPServer $ do
|
||||
connectUsers alice bob
|
||||
|
||||
alice #> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "file is above the limit of 100000 bytes: sender has no badge"
|
||||
bob ##> "/fr 1 ./tests/tmp"
|
||||
concurrentlyN_
|
||||
[ bob <## "file size exceeds the limit: test.pdf",
|
||||
alice <## "completed uploading file 1 (test.pdf) for bob"
|
||||
]
|
||||
where
|
||||
sndCfg = testCfg {fileSizeLimits = defaultFileSizeLimits {noBadge = 1000000}}
|
||||
rcvCfg = testCfg {fileSizeLimits = defaultFileSizeLimits {noBadge = 100000}}
|
||||
|
||||
testXFTPFileBadgeAboveLimit :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPFileBadgeAboveLimit ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
withNewTestChatCfg ps (badgeFileCfg pk) "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatCfg ps (rcvCfg pk) "bob" bobProfile $ \bob -> withXFTPServer $ do
|
||||
connectUsers alice bob
|
||||
addTestBadge alice =<< issueTestBadge sk futureDate
|
||||
|
||||
alice #> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "file is above the limit of 150000 bytes: above the limit of the sender badge"
|
||||
bob ##> "/fr 1 ./tests/tmp"
|
||||
concurrentlyN_
|
||||
[ bob <## "file size exceeds the limit: test.pdf",
|
||||
alice <## "completed uploading file 1 (test.pdf) for bob"
|
||||
]
|
||||
where
|
||||
rcvCfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 150000, legend = 400000}
|
||||
|
||||
testXFTPSndFileBadgeLimit :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPSndFileBadgeLimit ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
testChatCfg2 (cfg pk) aliceProfile bobProfile (test sk) ps
|
||||
where
|
||||
cfg pk = badgeFileCfgLimits pk FileSizeLimits {noBadge = 100000, supporter = 150000, legend = 300000}
|
||||
test sk alice bob = withXFTPServer $ do
|
||||
connectUsers alice bob
|
||||
|
||||
addTestBadge alice =<< issueTestBadgeType sk BTSupporter futureDate
|
||||
alice ##> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "file size exceeds the limit: ./tests/fixtures/test.pdf"
|
||||
|
||||
addTestBadge alice =<< issueTestBadgeType sk BTLegend futureDate
|
||||
alice #> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
concurrentlyN_
|
||||
[ alice <## "completed uploading file 1 (test.pdf) for bob",
|
||||
do
|
||||
bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
]
|
||||
|
||||
testXFTPSndFileBadgeGrace :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPSndFileBadgeGrace ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
testChatCfg2 (badgeFileCfg pk) aliceProfile bobProfile (test sk) ps
|
||||
where
|
||||
test sk alice bob = withXFTPServer $ do
|
||||
connectUsers alice bob
|
||||
now <- getCurrentTime
|
||||
|
||||
addTestBadge alice =<< issueTestBadge sk (addUTCTime (-3 * nominalDay) now)
|
||||
alice ##> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "file size exceeds the limit: ./tests/fixtures/test.pdf"
|
||||
|
||||
addTestBadge alice =<< issueTestBadge sk (addUTCTime (-3600) now)
|
||||
alice #> "/f @bob ./tests/fixtures/test.pdf"
|
||||
alice <## "use /fc 1 to cancel sending"
|
||||
concurrentlyN_
|
||||
[ alice <## "completed uploading file 1 (test.pdf) for bob",
|
||||
do
|
||||
bob <# "alice *> sends file test.pdf (266.0 KiB / 272376 bytes)"
|
||||
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
|
||||
]
|
||||
|
||||
testFileBadgeProofStatus :: HasCallStack => TestParams -> IO ()
|
||||
testFileBadgeProofStatus ps = do
|
||||
Right (pk, sk) <- bbsKeyGen
|
||||
withNewTestChatCfg ps (badgeFileCfg pk) "alice" aliceProfile $ \alice -> do
|
||||
now <- getCurrentTime
|
||||
let ph = PHFileInv {chatBinding = "Dalice-binding", fileSize = 272376}
|
||||
otherBinding = (ph :: ProofPresHeader) {chatBinding = "Dbob-binding"}
|
||||
otherSize = (ph :: ProofPresHeader) {fileSize = 1}
|
||||
proofFor expiry = do
|
||||
cred <- issueTestBadge sk expiry
|
||||
Right badge <- badgeProof pk cred ph
|
||||
pure badge
|
||||
statusOf expected badge = do
|
||||
Right st <- runExceptT (badgeProofStatus expected badge) `runReaderT` chatController alice
|
||||
pure st
|
||||
badge <- proofFor futureDate
|
||||
statusOf (Just ph) badge `shouldReturn` BSActive
|
||||
statusOf (Just otherBinding) badge `shouldReturn` BSFailed
|
||||
statusOf (Just otherSize) badge `shouldReturn` BSFailed
|
||||
-- the receiver has no binding for the sender, so no header can be expected
|
||||
statusOf Nothing badge `shouldReturn` BSFailed
|
||||
expired <- proofFor $ addUTCTime (-10 * nominalDay) now
|
||||
statusOf (Just ph) expired `shouldReturn` BSExpired
|
||||
|
||||
testXFTPDeleteUploadedFile :: HasCallStack => TestParams -> IO ()
|
||||
testXFTPDeleteUploadedFile =
|
||||
testChat2 aliceProfile bobProfile $ \alice bob -> do
|
||||
@@ -1037,11 +1225,11 @@ testProhibitFiles =
|
||||
alice <## "Files and media: off"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Files and media: off",
|
||||
do
|
||||
cath <## "alice updated group #team:"
|
||||
cath <## "alice updated group #team: (signed)"
|
||||
cath <## "updated group preferences:"
|
||||
cath <## "Files and media: off"
|
||||
]
|
||||
|
||||
@@ -128,7 +128,7 @@ testForwardChannelLinkRemoved ps =
|
||||
cath ##> "/set links #club off"
|
||||
cath <## "updated group preferences:"
|
||||
cath <## "SimpleX links: off"
|
||||
dan <## "cath updated group #club:"
|
||||
dan <## "cath updated group #club: (signed)"
|
||||
dan <## "updated group preferences:"
|
||||
dan <## "SimpleX links: off"
|
||||
alice #> "#team hi"
|
||||
|
||||
+703
-263
File diff suppressed because it is too large
Load Diff
+40
-37
@@ -281,10 +281,13 @@ futureDate = posixSecondsToUTCTime 4102444800 -- 2100-01-01
|
||||
|
||||
-- issue a supporter badge credential with the given expiry (test issuer)
|
||||
issueTestBadge :: BBSSecretKey -> UTCTime -> IO BadgeCredential
|
||||
issueTestBadge sk badgeExpiry = do
|
||||
issueTestBadge sk = issueTestBadgeType sk BTSupporter
|
||||
|
||||
issueTestBadgeType :: BBSSecretKey -> BadgeType -> UTCTime -> IO BadgeCredential
|
||||
issueTestBadgeType sk badgeType badgeExpiry = do
|
||||
drg <- C.newRandom
|
||||
mk <- generateMasterKey drg
|
||||
let info = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""}
|
||||
let info = BadgeInfo {badgeType, badgeExpiry, badgeExtra = ""}
|
||||
Just vreq <- verifyPayment (BPRedeemCode "TEST") BadgeRequest {masterKey = mk, badgeInfo = info}
|
||||
Right cred <- issueBadge 1 sk vreq
|
||||
pure cred
|
||||
@@ -1299,13 +1302,13 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c
|
||||
alice ##> "/p alisa"
|
||||
alice <## "user profile is changed to alisa (your 0 contacts are notified)"
|
||||
alice #> "#biz hello again" -- profile update is sent with message
|
||||
biz <## "alice_1 updated group #alice:"
|
||||
biz <## "alice_1 updated group #alice: (signed)"
|
||||
biz <## "changed to #alisa"
|
||||
biz <# "#alisa alisa_1> hello again"
|
||||
-- customer can invite members too, if business allows
|
||||
biz ##> "/mr alisa alisa_1 admin"
|
||||
biz <## "#alisa: you changed the role of alisa_1 to admin"
|
||||
alice <## "#biz: biz_1 changed your role from member to admin"
|
||||
biz <## "#alisa: you changed the role of alisa_1 to admin (signed)"
|
||||
alice <## "#biz: biz_1 changed your role from member to admin (signed)"
|
||||
connectUsers alice bob
|
||||
alice ##> "/a #biz bob"
|
||||
alice <## "invitation to join the group #biz sent to bob"
|
||||
@@ -1370,11 +1373,11 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c
|
||||
biz #> "#alisa hey"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
alice <## "biz_1 updated group #biz:"
|
||||
alice <## "biz_1 updated group #biz: (signed)"
|
||||
alice <## "changed to #business"
|
||||
alice <# "#business business_1> hey",
|
||||
do
|
||||
bob <## "biz_1 updated group #biz:"
|
||||
bob <## "biz_1 updated group #biz: (signed)"
|
||||
bob <## "changed to #business"
|
||||
bob <# "#business business_1> hey",
|
||||
do
|
||||
@@ -1387,15 +1390,15 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c
|
||||
biz <## "Full deletion: on"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
alice <## "business_1 updated group #business:"
|
||||
alice <## "business_1 updated group #business: (signed)"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Full deletion: on",
|
||||
do
|
||||
bob <## "business_1 updated group #business:"
|
||||
bob <## "business_1 updated group #business: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Full deletion: on",
|
||||
do
|
||||
cath <## "business updated group #alisa:"
|
||||
cath <## "business updated group #alisa: (signed)"
|
||||
cath <## "updated group preferences:"
|
||||
cath <## "Full deletion: on"
|
||||
]
|
||||
@@ -2093,11 +2096,11 @@ testJoinGroupIncognito =
|
||||
-- remove member
|
||||
alice ##> ("/rm secret_club " <> cathIncognito)
|
||||
concurrentlyN_
|
||||
[ alice <## ("#secret_club: you removed " <> cathIncognito <> " from the group"),
|
||||
bob <## ("#secret_club: alice removed " <> cathIncognito <> " from the group"),
|
||||
dan <## ("#secret_club: alice removed " <> cathIncognito <> " from the group"),
|
||||
[ alice <## ("#secret_club: you removed " <> cathIncognito <> " from the group (signed)"),
|
||||
bob <## ("#secret_club: alice removed " <> cathIncognito <> " from the group (signed)"),
|
||||
dan <## ("#secret_club: alice removed " <> cathIncognito <> " from the group (signed)"),
|
||||
do
|
||||
cath <## "#secret_club: alice removed you from the group"
|
||||
cath <## "#secret_club: alice removed you from the group (signed)"
|
||||
cath <## "use /d #secret_club to delete the group"
|
||||
]
|
||||
bob #> "#secret_club hi"
|
||||
@@ -2236,10 +2239,10 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr
|
||||
[ do
|
||||
bob <## "#team: you left the group"
|
||||
bob <## "use /d #team to delete the group",
|
||||
alice <## ("#team: " <> bobIncognito <> " left the group")
|
||||
alice <## ("#team: " <> bobIncognito <> " left the group (signed)")
|
||||
]
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted the group"
|
||||
bob <## "#team: you deleted your local copy of the group"
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
testDeleteGroupThenContactDeletesIncognitoProfile :: HasCallStack => TestParams -> IO ()
|
||||
@@ -2281,10 +2284,10 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr
|
||||
[ do
|
||||
bob <## "#team: you left the group"
|
||||
bob <## "use /d #team to delete the group",
|
||||
alice <## ("#team: " <> bobIncognito <> " left the group")
|
||||
alice <## ("#team: " <> bobIncognito <> " left the group (signed)")
|
||||
]
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted the group"
|
||||
bob <## "#team: you deleted your local copy of the group"
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
-- delete contact
|
||||
bob ##> "/d alice"
|
||||
@@ -2653,7 +2656,7 @@ testUpdateGroupPrefs =
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Full deletion: on"
|
||||
alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on")])
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Full deletion: on"
|
||||
threadDelay 500000
|
||||
@@ -2663,7 +2666,7 @@ testUpdateGroupPrefs =
|
||||
alice <## "Full deletion: off"
|
||||
alice <## "Voice messages: off"
|
||||
alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off")])
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Full deletion: off"
|
||||
bob <## "Voice messages: off"
|
||||
@@ -2673,7 +2676,7 @@ testUpdateGroupPrefs =
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Voice messages: on"
|
||||
alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "Full deletion: on"), (1, "Full deletion: off"), (1, "Voice messages: off"), (1, "Voice messages: on")])
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Voice messages: on"
|
||||
threadDelay 500000
|
||||
@@ -2726,7 +2729,7 @@ testAllowFullDeletionGroup =
|
||||
alice ##> "/set delete #team on"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Full deletion: on"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Full deletion: on"
|
||||
alice #$> ("/_get chat #1 count=100", chat, sndGroupFeatures <> [(0, "connected"), (1, "hi"), (0, "hey"), (1, "Full deletion: on")])
|
||||
@@ -2790,7 +2793,7 @@ testProhibitDirectMessages =
|
||||
where
|
||||
directProhibited :: HasCallStack => TestCC -> IO ()
|
||||
directProhibited cc = do
|
||||
cc <## "alice updated group #team:"
|
||||
cc <## "alice updated group #team: (signed)"
|
||||
cc <## "updated group preferences:"
|
||||
cc <## "Direct messages: off"
|
||||
|
||||
@@ -2846,7 +2849,7 @@ testEnableTimedMessagesGroup =
|
||||
alice ##> "/_group_profile #1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"timedMessages\": {\"enable\": \"on\", \"ttl\": 1}, \"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}}"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Disappearing messages: on (1 sec)"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Disappearing messages: on (1 sec)"
|
||||
threadDelay 1000000
|
||||
@@ -2864,7 +2867,7 @@ testEnableTimedMessagesGroup =
|
||||
alice ##> "/set disappear #team off"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Disappearing messages: off"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Disappearing messages: off"
|
||||
threadDelay 1000000
|
||||
@@ -2877,13 +2880,13 @@ testEnableTimedMessagesGroup =
|
||||
alice ##> "/set disappear #team on 30s"
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Disappearing messages: on (30 sec)"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Disappearing messages: on (30 sec)"
|
||||
alice ##> "/set disappear #team week" -- "on" is optional
|
||||
alice <## "updated group preferences:"
|
||||
alice <## "Disappearing messages: on (1 week)"
|
||||
bob <## "alice updated group #team:"
|
||||
bob <## "alice updated group #team: (signed)"
|
||||
bob <## "updated group preferences:"
|
||||
bob <## "Disappearing messages: on (1 week)"
|
||||
|
||||
@@ -3001,7 +3004,7 @@ testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danP
|
||||
where
|
||||
directForOwners :: HasCallStack => TestCC -> IO ()
|
||||
directForOwners cc = do
|
||||
cc <## "alice updated group #team:"
|
||||
cc <## "alice updated group #team: (signed)"
|
||||
cc <## "updated group preferences:"
|
||||
cc <## "Direct messages: on for owners"
|
||||
|
||||
@@ -3036,7 +3039,7 @@ testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $
|
||||
where
|
||||
filesForOwners :: HasCallStack => TestCC -> IO ()
|
||||
filesForOwners cc = do
|
||||
cc <## "alice updated group #team:"
|
||||
cc <## "alice updated group #team: (signed)"
|
||||
cc <## "updated group preferences:"
|
||||
cc <## "Files and media: on for owners"
|
||||
|
||||
@@ -3078,7 +3081,7 @@ testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfil
|
||||
where
|
||||
linksForOwners :: HasCallStack => TestCC -> IO ()
|
||||
linksForOwners cc = do
|
||||
cc <## "alice updated group #team:"
|
||||
cc <## "alice updated group #team: (signed)"
|
||||
cc <## "updated group preferences:"
|
||||
cc <## "SimpleX links: on for owners"
|
||||
|
||||
@@ -3724,10 +3727,10 @@ testShortLinkAddressPrepareBusiness = testChat3 businessProfile aliceProfile {fu
|
||||
bob <## "business address: known business #biz"
|
||||
bob <## "use #biz <message> to send messages"
|
||||
biz ##> "/d #bob"
|
||||
biz <## "#bob: you deleted the group"
|
||||
alice <## "#bob: biz deleted the group"
|
||||
biz <## "#bob: you deleted the group (signed)"
|
||||
alice <## "#bob: biz deleted the group (signed)"
|
||||
alice <## "use /d #bob to delete the local copy of the group"
|
||||
bob <## "#biz: biz_1 deleted the group"
|
||||
bob <## "#biz: biz_1 deleted the group (signed)"
|
||||
bob <## "use /d #biz to delete the local copy of the group"
|
||||
bob ##> ("/_connect plan 1 " <> shortLink)
|
||||
bob <## "business address: ok to connect"
|
||||
@@ -3820,8 +3823,8 @@ testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile test
|
||||
bob ##> "/l #team"
|
||||
bob <## "#team: you left the group"
|
||||
bob <## "use /d #team to delete the group"
|
||||
alice <## "#team: bob left the group"
|
||||
cath <## "#team: bob left the group"
|
||||
alice <## "#team: bob left the group (signed)"
|
||||
cath <## "#team: bob left the group (signed)"
|
||||
bob ##> ("/_connect plan 1 " <> shortLink)
|
||||
bob <## "group link: ok to connect directly"
|
||||
void $ getTermLine bob
|
||||
@@ -4483,7 +4486,7 @@ testShortLinkGroupChangeProfile = testChat3 aliceProfile bobProfile cathProfile
|
||||
|
||||
alice ##> "/gp team club"
|
||||
alice <## "changed to #club"
|
||||
cath <## "alice updated group #team:"
|
||||
cath <## "alice updated group #team: (signed)"
|
||||
cath <## "changed to #club"
|
||||
|
||||
bob ##> ("/_connect plan 1 " <> shortLink)
|
||||
@@ -4521,7 +4524,7 @@ testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cath
|
||||
|
||||
cath ##> "/gp team club"
|
||||
cath <## "changed to #club"
|
||||
alice <## "cath updated group #team:"
|
||||
alice <## "cath updated group #team: (signed)"
|
||||
alice <## "changed to #club"
|
||||
threadDelay 250000
|
||||
|
||||
|
||||
+19
-19
@@ -188,7 +188,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"9\",\"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\":\"9-19\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
"{\"v\":\"9-20\",\"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\":\"9\",\"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\"}}}}"
|
||||
@@ -225,10 +225,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just (testForwardLink {memberId = Nothing} :: ForwardLink)) (MCText "hello"))
|
||||
it "x.msg.new simple text with file" $
|
||||
"{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
#==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}})
|
||||
#==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}})
|
||||
it "x.msg.new simple file with file" $
|
||||
"{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"file\"},\"file\":{\"fileSize\":12345,\"fileName\":\"file.txt\"}}}"
|
||||
#==# XMsgNew ((mcSimple (MCFile "")) {file = Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}})
|
||||
#==# XMsgNew ((mcSimple (MCFile "")) {file = Just FileInvitation {fileName = "file.txt", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}})
|
||||
it "x.msg.new quote with file" $
|
||||
"{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}"
|
||||
##==## ChatMessage
|
||||
@@ -236,7 +236,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
(Just $ SharedMsgId "\1\2\3\4")
|
||||
( XMsgNew
|
||||
(mcQuote quotedMsg (MCText "hello to you too"))
|
||||
{file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}
|
||||
{file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}}
|
||||
)
|
||||
it "x.msg.new report" $
|
||||
"{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"spam\",\"type\":\"report\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}"
|
||||
@@ -246,7 +246,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
(XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam)))
|
||||
it "x.msg.new forward with file" $
|
||||
"{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}"
|
||||
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}})
|
||||
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}})
|
||||
it "x.msg.update" $
|
||||
"{\"v\":\"9\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
#==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing
|
||||
@@ -258,10 +258,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
#==# XMsgDeleted
|
||||
it "x.file" $
|
||||
"{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"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%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing}
|
||||
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}
|
||||
it "x.file without file invitation" $
|
||||
"{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
|
||||
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}
|
||||
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}
|
||||
it "x.file.acpt" $
|
||||
"{\"v\":\"9\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}"
|
||||
#==# XFileAcpt "photo.jpg"
|
||||
@@ -276,42 +276,42 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
#==# XFileCancel (SharedMsgId "\1\2\3\4")
|
||||
it "x.info" $
|
||||
"{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
#==# XInfo testProfile
|
||||
#==# XInfo testProfile Nothing
|
||||
it "x.info with empty full name" $
|
||||
"{\"v\":\"9\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
#==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}
|
||||
#==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} Nothing
|
||||
it "x.contact with xContactId" $
|
||||
"{\"v\":\"9\",\"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
|
||||
#==# XContact testProfile Nothing (Just $ XContactId "\1\2\3\4") Nothing Nothing
|
||||
it "x.contact without XContactId" $
|
||||
"{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
#==# XContact testProfile Nothing Nothing Nothing
|
||||
#==# XContact testProfile Nothing Nothing Nothing Nothing
|
||||
it "x.contact with content null" $
|
||||
"{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
==# XContact testProfile Nothing Nothing Nothing
|
||||
==# XContact testProfile Nothing Nothing Nothing Nothing
|
||||
it "x.contact with content" $
|
||||
"{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"msgId\":\"AQIDBA==\",\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
==# XContact testProfile Nothing Nothing (Just (SharedMsgId "\1\2\3\4", MCText {text = "hello"}))
|
||||
==# XContact testProfile Nothing Nothing Nothing (Just (SharedMsgId "\1\2\3\4", MCText {text = "hello"}))
|
||||
it "x.grp.inv" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"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%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing}
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing}
|
||||
it "x.grp.inv with group link id" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"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%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}"
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing}
|
||||
#==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing}
|
||||
it "x.grp.acpt without incognito profile" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
||||
#==# XGrpAcpt (MemberId "\1\2\3\4")
|
||||
#==# XGrpAcpt (MemberId "\1\2\3\4") Nothing
|
||||
it "x.grp.mem.new" $
|
||||
"{\"v\":\"9\",\"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\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"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\":\"9\",\"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\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"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\":\"9\",\"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\"}}}}}}"
|
||||
@@ -326,7 +326,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"9\",\"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%3D3%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%3D3%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\":\"9\",\"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%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"9\",\"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%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"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\":\"9\",\"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\"}}}}}"
|
||||
|
||||
@@ -892,6 +892,21 @@ templateEngineOverride: njk
|
||||
}
|
||||
.cf-form-submit:hover { filter: brightness(1.07); }
|
||||
.cf-form-submit:active { transform: translateY(1px); }
|
||||
.cf-simplex-link {
|
||||
display: inline-block;
|
||||
margin-top: 18px;
|
||||
font-family: "Manrope", sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
color: #64FDFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
.cf-simplex-link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.cf-simplex-link svg { display: inline-block; width: .82em; height: .82em; margin-left: .4em; fill: currentColor; }
|
||||
.cf-form-note { margin-top: 18px; font-size: .85rem; color: #9fb2d8; }
|
||||
.cf-modal-light .cf-simplex-link { color: #0053d0; }
|
||||
.cf-modal-light .cf-form-note { color: #6b7478; }
|
||||
|
||||
.cf-deck-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -941,6 +956,12 @@ templateEngineOverride: njk
|
||||
.cf-modal-img { width: 80%; align-self: center; }
|
||||
.cf-modal-panel h2 { font-size: 1.4rem; }
|
||||
}
|
||||
/* 320pt phones: the card's text column is narrower than the submit label */
|
||||
@media screen and (max-width: 389px) {
|
||||
.cf-modal { padding: 24px 12px; }
|
||||
.cf-modal-panel { padding: 28px 20px; }
|
||||
.cf-form-submit { padding: 0 12px; font-size: 15px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -1221,9 +1242,10 @@ templateEngineOverride: njk
|
||||
<div class="cf-modal-panel" role="dialog" aria-modal="true" aria-labelledby="request-title">
|
||||
<a class="cf-modal-close" href="#!" aria-label="Close"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 2 L22 22 M22 2 L2 22"/></svg></a>
|
||||
<div class="cf-modal-text">
|
||||
<h2 id="request-title">Learn more about investing in SimpleX Chat</h2>
|
||||
<h2 id="request-title">Where to send the deck</h2>
|
||||
<p>We will send the deck and updates about the crowdfunding.</p>
|
||||
<form class="cf-form" action="https://chat.us2.list-manage.com/subscribe/post?u=ddd892b258ae36e5438e6d4e1&id=de405d59d2&f_id=001ef8e3f0&legacy_form=1" method="post" target="_blank">
|
||||
<input type="text" name="FNAME" placeholder="Name or pseudonym" aria-label="Name or pseudonym">
|
||||
<input type="text" name="FNAME" placeholder="Name (optional)" aria-label="Name, optional">
|
||||
<input type="email" name="EMAIL" placeholder="Email*" aria-label="Email address" required>
|
||||
<input type="hidden" name="PAGE" value="crowdfunding">
|
||||
<input type="hidden" name="SOURCE" value="crowdfunding_page">
|
||||
@@ -1231,8 +1253,10 @@ templateEngineOverride: njk
|
||||
<span aria-hidden="true" class="cf-bot-field">
|
||||
<input type="text" name="b_ddd892b258ae36e5438e6d4e1_de405d59d2" tabindex="-1" value="">
|
||||
</span>
|
||||
<button type="submit" name="subscribe" value="Subscribe" class="cf-form-submit">Submit and download our Pitch deck</button>
|
||||
<button type="submit" name="subscribe" value="Subscribe" class="cf-form-submit">Submit and download our deck</button>
|
||||
</form>
|
||||
<a class="cf-simplex-link" href="https://smp11.simplex.im/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo" target="_blank" rel="noopener">Or connect to our team via SimpleX Chat<svg viewBox="0 0 100 100" aria-hidden="true"><path d="M18.8,85.1h56l0,0c2.2,0,4-1.8,4-4v-32h-8v28h-48v-48h28v-8h-32l0,0c-2.2,0-4,1.8-4,4v56C14.8,83.3,16.6,85.1,18.8,85.1z"></path><polygon points="45.7,48.7 51.3,54.3 77.2,28.5 77.2,37.2 85.2,37.2 85.2,14.9 62.8,14.9 62.8,22.9 71.5,22.9"></polygon></svg></a>
|
||||
<p class="cf-form-note">We use Mailchimp to send emails.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,7 +79,7 @@ templateEngineOverride: njk
|
||||
|
||||
<div class="actions">
|
||||
<a class="register-btn" href="https://use.simplex.chat/livestream" target="_blank" rel="noopener">Get event updates</a>
|
||||
<a class="invest-link gradient-text" href="https://wefunder.com/simplex.chat?utm_source=livestream_page" target="_blank" rel="noopener">Learn more and invest on Wefunder</a>
|
||||
<a class="invest-link gradient-text" href="/crowdfunding/">Get a stake in SimpleX Chat</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,7 +105,7 @@ templateEngineOverride: njk
|
||||
<input type="submit" name="subscribe" value="Register">
|
||||
</form>
|
||||
|
||||
<a class="channel-link" href="https://simplex.chat/crowdfunding-news/" target="_blank" rel="noopener">Or join our SimpleX Crowdfunding News channel<svg viewBox="0 0 100 100" aria-hidden="true"><path d="M18.8,85.1h56l0,0c2.2,0,4-1.8,4-4v-32h-8v28h-48v-48h28v-8h-32l0,0c-2.2,0-4,1.8-4,4v56C14.8,83.3,16.6,85.1,18.8,85.1z"></path><polygon points="45.7,48.7 51.3,54.3 77.2,28.5 77.2,37.2 85.2,37.2 85.2,14.9 62.8,14.9 62.8,22.9 71.5,22.9"></polygon></svg></a>
|
||||
<a class="channel-link" href="https://smp11.simplex.im/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo" target="_blank" rel="noopener">Or connect to our team via SimpleX Chat<svg viewBox="0 0 100 100" aria-hidden="true"><path d="M18.8,85.1h56l0,0c2.2,0,4-1.8,4-4v-32h-8v28h-48v-48h28v-8h-32l0,0c-2.2,0-4,1.8-4,4v56C14.8,83.3,16.6,85.1,18.8,85.1z"></path><polygon points="45.7,48.7 51.3,54.3 77.2,28.5 77.2,37.2 85.2,37.2 85.2,14.9 62.8,14.9 62.8,22.9 71.5,22.9"></polygon></svg></a>
|
||||
|
||||
<p class="register-note">We use Mailchimp to deliver updates via email</p>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user