ios: check known relays before file reception, support user approval of unknown relays (#4044)

* ios: check known relays before file reception, support user approval of unknown relays

* add privacyAskToApproveRelays to AppSettings

* less text

* ui improvements, fixes

* fix ntf

* shorter alert

* simplify settings

* rename

* icon

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndy
2024-05-20 17:49:19 +04:00
committed by GitHub
parent d2d450d1d7
commit ec7b35adb9
13 changed files with 121 additions and 41 deletions
+45 -9
View File
@@ -927,14 +927,19 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
}
}
func receiveFile(user: any UserLike, fileId: Int64, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(fileId: fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get(), auto: auto) {
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
if let chatItem = await apiReceiveFile(
fileId: fileId,
userApprovedRelays: userApprovedRelays || !privacyAskToApproveRelaysGroupDefault.get(),
encrypted: privacyEncryptLocalFilesGroupDefault.get(),
auto: auto
) {
await chatItemSimpleUpdate(user, chatItem)
}
}
func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, inline: Bool? = nil, auto: Bool = false) async -> AChatItem? {
let r = await chatSendCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
let am = AlertManager.shared
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
if case .rcvFileAcceptedSndCancelled = r {
@@ -947,19 +952,50 @@ func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil, auto: B
}
} else if let networkErrorAlert = networkErrorAlert(r) {
logger.error("apiReceiveFile network error: \(String(describing: r))")
am.showAlert(networkErrorAlert)
if !auto {
am.showAlert(networkErrorAlert)
}
} else {
switch chatError(r) {
case .fileCancelled:
logger.debug("apiReceiveFile ignoring fileCancelled error")
case .fileAlreadyReceiving:
logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error")
case let .fileNotApproved(fileId, unknownServers):
logger.debug("apiReceiveFile fileNotApproved error")
if !auto {
let srvs = unknownServers.map { s in
if let srv = parseServerAddress(s), !srv.hostnames.isEmpty {
srv.hostnames[0]
} else {
serverHost(s)
}
}
am.showAlert(Alert(
title: Text("Unknown servers!"),
message: Text("Without Tor or VPN, your IP address will be visible to these XFTP relays: \(srvs.sorted().joined(separator: ", "))."),
primaryButton: .default(
Text("Download"),
action: {
Task {
logger.debug("apiReceiveFile fileNotApproved alert - in Task")
if let user = ChatModel.shared.currentUser {
await receiveFile(user: user, fileId: fileId, userApprovedRelays: true)
}
}
}
),
secondaryButton: .cancel()
))
}
default:
logger.error("apiReceiveFile error: \(String(describing: r))")
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
if !auto {
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
)
}
}
}
return nil
@@ -49,7 +49,7 @@ func localizedInfoRow(_ title: LocalizedStringKey, _ value: LocalizedStringKey)
}
}
private func serverHost(_ s: String) -> String {
func serverHost(_ s: String) -> String {
if let i = s.range(of: "@")?.lowerBound {
return String(s[i...].dropFirst())
} else {
@@ -60,6 +60,7 @@ struct CIFileView: View {
case .rcvInvitation: return true
case .rcvAccepted: return true
case .rcvTransfer: return false
case .rcvAborted: return true
case .rcvComplete: return true
case .rcvCancelled: return false
case .rcvError: return false
@@ -73,10 +74,10 @@ struct CIFileView: View {
logger.debug("CIFileView fileAction")
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation:
case .rcvInvitation, .rcvAborted:
if fileSizeValid(file) {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
}
@@ -148,6 +149,8 @@ struct CIFileView: View {
} else {
progressView()
}
case .rcvAborted:
fileIcon("doc.fill", color: .accentColor, innerIcon: "exclamationmark.arrow.circlepath", innerIconSize: 12)
case .rcvComplete: fileIcon("doc.fill")
case .rcvCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .rcvError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
@@ -17,7 +17,6 @@ struct CIImageView: View {
let maxWidth: CGFloat
@Binding var imgWidth: CGFloat?
@State var scrollProxy: ScrollViewProxy?
@State var metaColor: Color
@State private var showFullScreenImage = false
var body: some View {
@@ -38,7 +37,7 @@ struct CIImageView: View {
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
case .rcvInvitation, .rcvAborted:
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
@@ -103,6 +102,7 @@ struct CIImageView: View {
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case .rcvTransfer: progressView()
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
@@ -116,7 +116,7 @@ struct CIImageView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(metaColor)
.foregroundColor(.white)
.padding(padding)
}
@@ -69,7 +69,7 @@ struct CIVideoView: View {
.onTapGesture {
if let file = file {
switch file.fileStatus {
case .rcvInvitation:
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
case .rcvAccepted:
switch file.fileProtocol {
@@ -95,7 +95,7 @@ struct CIVideoView: View {
}
durationProgress()
}
if let file = file, case .rcvInvitation = file.fileStatus {
if let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
@@ -105,6 +105,14 @@ struct CIVideoView: View {
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
private func videoViewEncrypted(_ file: CIFile, _ defaultPreview: UIImage, _ duration: Int) -> some View {
return ZStack(alignment: .topTrailing) {
ZStack(alignment: .center) {
@@ -280,6 +288,7 @@ struct CIVideoView: View {
} else {
progressView()
}
case .rcvAborted: fileIcon("exclamationmark.arrow.circlepath", 14, 11)
case .rcvCancelled: fileIcon("xmark", 10, 13)
case .rcvError: fileIcon("xmark", 10, 13)
case .invalid: fileIcon("questionmark", 10, 13)
@@ -318,10 +327,10 @@ struct CIVideoView: View {
}
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool) async -> Void) {
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false)
await receiveFile(user, file.fileId, false, false)
}
}
}
@@ -139,9 +139,10 @@ struct VoiceMessagePlayer: View {
case .sndComplete: playbackButton()
case .sndCancelled: playbackButton()
case .sndError: playbackButton()
case .rcvInvitation: downloadButton(recordingFile)
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon()
case .rcvAborted: downloadButton(recordingFile, "exclamationmark.arrow.circlepath")
case .rcvComplete: playbackButton()
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case .rcvError: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
@@ -217,7 +218,7 @@ struct VoiceMessagePlayer: View {
}
}
private func downloadButton(_ recordingFile: CIFile) -> some View {
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Button {
Task {
if let user = chatModel.currentUser {
@@ -225,7 +226,7 @@ struct VoiceMessagePlayer: View {
}
}
} label: {
playPauseIcon("play.fill")
playPauseIcon(icon)
}
}
@@ -115,7 +115,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, image):
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy, metaColor: metaColor)
CIImageView(chatItem: chatItem, image: image, maxWidth: maxWidth, imgWidth: $imgWidth, scrollProxy: scrollProxy)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -23,6 +23,7 @@ extension AppSettings {
setNetCfg(val)
}
if let val = privacyEncryptLocalFiles { privacyEncryptLocalFilesGroupDefault.set(val) }
if let val = privacyAskToApproveRelays { privacyAskToApproveRelaysGroupDefault.set(val) }
if let val = privacyAcceptImages {
privacyAcceptImagesGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)
@@ -50,6 +51,7 @@ extension AppSettings {
var c = AppSettings.defaults
c.networkConfig = getNetCfg()
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
@@ -16,6 +16,7 @@ struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -64,18 +65,6 @@ struct PrivacySettings: View {
}
Section {
settingsRow("lock.doc") {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("network") {
Toggle("Send link previews", isOn: $useLinkPreviews)
}
@@ -108,6 +97,32 @@ struct PrivacySettings: View {
Text("Chats")
}
Section {
settingsRow("lock.doc") {
Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
}
settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages)
.onChange(of: autoAcceptImages) {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("network.badge.shield.half.filled") {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
} header: {
Text("Files")
} footer: {
if askToApproveRelays {
Text("The app will ask to confirm downloads from unknown file servers (except .onion).")
} else {
Text("Without Tor or VPN, your IP address will be visible to file servers.")
}
}
Section {
settingsRow("person") {
Toggle("Contacts", isOn: $contactReceipts)
@@ -696,14 +696,16 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
}
func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? {
let r = sendSimpleXCmd(.receiveFile(fileId: fileId, encrypted: encrypted, inline: inline))
let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get()
let r = sendSimpleXCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
logger.error("receiveFile error: \(responseError(r))")
return nil
}
func apiSetFileToReceive(fileId: Int64, encrypted: Bool) {
let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, encrypted: encrypted))
let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get()
let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted))
if case .cmdOk = r { return }
logger.error("setFileToReceive error: \(responseError(r))")
}
+8 -4
View File
@@ -123,8 +123,8 @@ public enum ChatCommand {
case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, encrypted: Bool?)
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
case cancelFile(fileId: Int64)
// remote desktop commands
case setLocalDeviceName(displayName: String)
@@ -282,8 +282,8 @@ public enum ChatCommand {
case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))"
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
case let .cancelFile(fileId): return "/fcancel \(fileId)"
case let .setLocalDeviceName(displayName): return "/set device name \(displayName)"
case let .connectRemoteCtrl(xrcpInv): return "/connect remote ctrl \(xrcpInv)"
@@ -1760,6 +1760,7 @@ public enum ChatErrorType: Decodable {
case fileImageType(filePath: String)
case fileImageSize(filePath: String)
case fileNotReceived(fileId: Int64)
case fileNotApproved(fileId: Int64, unknownServers: [String])
// case xFTPRcvFile
// case xFTPSndFile
case fallbackToSMPProhibited(fileId: Int64)
@@ -2038,6 +2039,7 @@ public struct MigrationFileLinkData: Codable {
public struct AppSettings: Codable, Equatable {
public var networkConfig: NetCfg? = nil
public var privacyEncryptLocalFiles: Bool? = nil
public var privacyAskToApproveRelays: Bool? = nil
public var privacyAcceptImages: Bool? = nil
public var privacyLinkPreviews: Bool? = nil
public var privacyShowChatPreviews: Bool? = nil
@@ -2061,6 +2063,7 @@ public struct AppSettings: Codable, Equatable {
let def = AppSettings.defaults
if networkConfig != def.networkConfig { empty.networkConfig = networkConfig }
if privacyEncryptLocalFiles != def.privacyEncryptLocalFiles { empty.privacyEncryptLocalFiles = privacyEncryptLocalFiles }
if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
@@ -2085,6 +2088,7 @@ public struct AppSettings: Codable, Equatable {
AppSettings (
networkConfig: NetCfg.defaults,
privacyEncryptLocalFiles: true,
privacyAskToApproveRelays: true,
privacyAcceptImages: true,
privacyLinkPreviews: true,
privacyShowChatPreviews: true,
+4
View File
@@ -23,6 +23,7 @@ public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used
public let GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES = "privacyEncryptLocalFiles"
public let GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS = "privacyAskToApproveRelays"
let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount"
let GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS = "networkUseOnionHosts"
let GROUP_DEFAULT_NETWORK_SESSION_MODE = "networkSessionMode"
@@ -73,6 +74,7 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
@@ -181,6 +183,8 @@ public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults
public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES)
public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS)
public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT)
public let networkUseOnionHostsGroupDefault = EnumDefault<OnionHosts>(
+4
View File
@@ -3174,6 +3174,7 @@ public struct CIFile: Decodable {
case .rcvInvitation: return false
case .rcvAccepted: return false
case .rcvTransfer: return false
case .rcvAborted: return false
case .rcvCancelled: return false
case .rcvComplete: return true
case .rcvError: return false
@@ -3198,6 +3199,7 @@ public struct CIFile: Decodable {
case .rcvInvitation: return nil
case .rcvAccepted: return rcvCancelAction
case .rcvTransfer: return rcvCancelAction
case .rcvAborted: return nil
case .rcvCancelled: return nil
case .rcvComplete: return nil
case .rcvError: return nil
@@ -3312,6 +3314,7 @@ public enum CIFileStatus: Decodable, Equatable {
case rcvInvitation
case rcvAccepted
case rcvTransfer(rcvProgress: Int64, rcvTotal: Int64)
case rcvAborted
case rcvComplete
case rcvCancelled
case rcvError
@@ -3327,6 +3330,7 @@ public enum CIFileStatus: Decodable, Equatable {
case .rcvInvitation: return "rcvInvitation"
case .rcvAccepted: return "rcvAccepted"
case let .rcvTransfer(rcvProgress, rcvTotal): return "rcvTransfer \(rcvProgress) \(rcvTotal)"
case .rcvAborted: return "rcvAborted"
case .rcvComplete: return "rcvComplete"
case .rcvCancelled: return "rcvCancelled"
case .rcvError: return "rcvError"