diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index 02cdad715e..b9bf6dd63a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -30,7 +30,7 @@ struct CILinkView: View { VStack(alignment: .leading, spacing: 6) { Text(linkPreview.title) .lineLimit(3) - Text(linkPreview.uri.absoluteString) + Text(linkPreview.uri) .font(.caption) .lineLimit(1) .foregroundColor(theme.colors.secondary) @@ -44,25 +44,71 @@ struct CILinkView: View { } } -func openBrowserAlert(uri: URL) { +func openBrowserAlert(uri: String) { + let (url, err) = sanitizeUri(uri) + if let url { + let uriStr = url.uri.absoluteString + showAlert( + NSLocalizedString("Open link?", comment: "alert title"), + message: uriStr.count > 160 ? "\(uriStr.prefix(160))…" : uriStr, + actions: { + if let sanitizedUri = url.sanitizedUri { + [ + cancelAlertAction, + UIAlertAction( + title: NSLocalizedString("Open full link", comment: "alert action"), + style: .destructive, + handler: { _ in UIApplication.shared.open(url.uri) } + ), + UIAlertAction( + title: NSLocalizedString("Open clean link", comment: "alert action"), + style: .default, + handler: { _ in UIApplication.shared.open(sanitizedUri) } + ) + ] + } else { + [ + cancelAlertAction, + UIAlertAction( + title: NSLocalizedString("Open", comment: "alert action"), + style: .default, + handler: { _ in UIApplication.shared.open(url.uri) } + ) + ] + } + } + ) + } else { + showInvalidLinkAlert(uri, error: err) + } +} + +func showInvalidLinkAlert(_ uri: String, error: String? = nil) { + let message = if let error, !error.isEmpty { + error + "\n" + uri + } else { + uri + } showAlert( - NSLocalizedString("Open link?", comment: "alert title"), - message: uri.absoluteString, - actions: {[ - cancelAlertAction, - UIAlertAction( - title: NSLocalizedString("Open", comment: "alert action"), - style: .default, - handler: { _ in UIApplication.shared.open(uri) } - ) - ]} + NSLocalizedString("Invalid link", comment: "alert title"), + message: message, + actions: {[okAlertAction]} ) } +func sanitizeUri(_ s: String) -> (url: (uri: URL, sanitizedUri: URL?)?, error: String?) { + let parsed = parseSanitizeUri(s) + return if let uri = URL(string: s), let uriInfo = parsed?.uriInfo { + (url: (uri: uri, sanitizedUri: uriInfo.sanitized.flatMap { URL(string: $0) }), error: nil) + } else { + (url: nil, error: parsed?.parseError) + } +} + struct LargeLinkPreview_Previews: PreviewProvider { static var previews: some View { let preview = LinkPreview( - uri: URL(string: "http://DuckDuckGo.com")!, + uri: "http://DuckDuckGo.com", title: "Privacy, simplified.", description: "", image: "data:image/jpg;base64,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" diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index e743e0bffa..2a1b526893 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -187,23 +187,25 @@ private func handleTextTaps( } } } - if let index, let (url, browser) = attributedStringLink(s, for: index) { + if let index, let (uri, browser) = attributedStringLink(s, for: index) { if browser { - openBrowserAlert(uri: url) - } else { + openBrowserAlert(uri: uri) + } else if let url = URL(string: uri) { UIApplication.shared.open(url) + } else { + showInvalidLinkAlert(uri) } } }) } - func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? { - var linkURL: URL? + func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? { + var linkURL: String? var browser: Bool = false s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in if index >= range.location && index < range.location + range.length { - if let url = attrs[linkAttrKey] as? NSURL { - linkURL = url.absoluteURL + if let url = attrs[linkAttrKey] as? String { + linkURL = url browser = attrs[webLinkAttrKey] != nil } else if let showSecrets, let i = attrs[secretAttrKey] as? Int { if showSecrets.wrappedValue.contains(i) { @@ -356,22 +358,32 @@ func messageText( case .uri: attrs = linkAttrs() if !preview { - let s = t.lowercased() - let link = s.hasPrefix("http://") || s.hasPrefix("https://") + let link = t.hasPrefix("http://") || t.hasPrefix("https://") ? t : "https://" + t - attrs[linkAttrKey] = NSURL(string: link) + attrs[linkAttrKey] = link attrs[webLinkAttrKey] = true handleTaps = true } - case let .simplexLink(linkType, simplexUri, smpHosts): + case let .hyperLink(text, uri): attrs = linkAttrs() + if let text { t = text } if !preview { - attrs[linkAttrKey] = NSURL(string: simplexUri) + attrs[linkAttrKey] = uri + attrs[webLinkAttrKey] = true handleTaps = true } - if case .description = privacySimplexLinkModeDefault.get() { - t = simplexLinkText(linkType, smpHosts) + case let .simplexLink(text, linkType, simplexUri, smpHosts): + attrs = linkAttrs() + if !preview { + attrs[linkAttrKey] = simplexUri + handleTaps = true + } + if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) { + res.append(NSAttributedString(string: s + " ", attributes: attrs)) + italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize) + attrs[.font] = italic + t = viaHost(smpHosts) } case let .command(cmdStr): snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular) @@ -403,13 +415,13 @@ func messageText( case .email: attrs = linkAttrs() if !preview { - attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text) + attrs[linkAttrKey] = "mailto:" + ft.text handleTaps = true } case .phone: attrs = linkAttrs() if !preview { - attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: "")) + attrs[linkAttrKey] = "tel:" + t.replacingOccurrences(of: " ", with: "") handleTaps = true } case .unknown: () @@ -439,7 +451,11 @@ private func mentionText(_ name: String) -> String { } func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { - linkType.description + " " + "(via \(smpHosts.first ?? "?"))" + linkType.description + " " + viaHost(smpHosts) +} + +func viaHost(_ smpHosts: [String]) -> String { + "(via \(smpHosts.first ?? "?"))" } struct MsgContentView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift index e629a984df..878ebd9cbf 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift @@ -49,7 +49,7 @@ struct ComposeLinkView: View { VStack(alignment: .center, spacing: 4) { Text(linkPreview.title) .lineLimit(1) - Text(linkPreview.uri.absoluteString) + Text(linkPreview.uri) .font(.caption) .lineLimit(1) .foregroundColor(theme.colors.secondary) @@ -63,7 +63,7 @@ struct ComposeLinkView: View { struct SmallLinkPreview_Previews: PreviewProvider { static var previews: some View { let preview = LinkPreview( - uri: URL(string: "http://DuckDuckGo.com")!, + uri: "http://DuckDuckGo.com", title: "Privacy, simplified.", description: "", image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z" diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index a6e6518e7b..619f0b95f3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -329,10 +329,10 @@ struct ComposeView: View { @Binding var selectedRange: NSRange var disabledText: LocalizedStringKey? = nil - @State var linkUrl: URL? = nil + @State var linkUrl: String? = nil @State var hasSimplexLink: Bool = false - @State var prevLinkUrl: URL? = nil - @State var pendingLinkUrl: URL? = nil + @State var prevLinkUrl: String? = nil + @State var pendingLinkUrl: String? = nil @State var cancelledLinks: Set = [] @Environment(\.colorScheme) private var colorScheme @@ -353,6 +353,8 @@ struct ComposeView: View { @UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false + @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = true + @State private var updatingCompose = false var body: some View { VStack(spacing: 0) { @@ -454,8 +456,26 @@ struct ComposeView: View { .ignoresSafeArea(.all, edges: .bottom) } .onChange(of: composeState.message) { msg in - let parsedMsg = parseSimpleXMarkdown(msg) - composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg)) + if updatingCompose { + updatingCompose = false + return + } + var parsedMsg = parseSimpleXMarkdown(msg) + if privacySanitizeLinks, let parsed = parsedMsg { + let r = sanitizeMessage(parsed) + if let sanitizedPos = r.sanitizedPos { + updatingCompose = true + composeState = composeState.copy(message: r.message, parsedMessage: r.parsedMsg) + if sanitizedPos < selectedRange.location { + selectedRange = NSRange(location: sanitizedPos, length: 0) + } + parsedMsg = r.parsedMsg + } else { + composeState = composeState.copy(parsedMessage: parsedMsg) + } + } else { + composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg)) + } if composeState.linkPreviewAllowed { if msg.count > 0 { showLinkPreview(parsedMsg) @@ -464,7 +484,7 @@ struct ComposeView: View { hasSimplexLink = false } } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { - (_, hasSimplexLink) = getSimplexLink(parsedMsg) + (_, hasSimplexLink) = getMessageLinks(parsedMsg) } else { hasSimplexLink = false } @@ -845,7 +865,7 @@ struct ComposeView: View { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): if let parsedMsg = parseSimpleXMarkdown(msgText), - let url = getSimplexLink(parsedMsg).url, + let url = getMessageLinks(parsedMsg).url, let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: msgText, preview: linkPreview) @@ -1448,7 +1468,7 @@ struct ComposeView: View { private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl - (linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg) + (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg) if let url = linkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url @@ -1465,39 +1485,38 @@ struct ComposeView: View { } } - private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) { + private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) { guard let parsedMsg else { return (nil, false) } - let url: URL? = if let uri = parsedMsg.first(where: { ft in - ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) - }) { - URL(string: uri.text) - } else { - nil - } let simplexLink = parsedMsgHasSimplexLink(parsedMsg) - return (url, simplexLink) + for ft in parsedMsg { + if let link = ft.linkUri, !cancelledLinks.contains(link) && !isSimplexLink(link) { + return (link, simplexLink) + } + } + return (nil, simplexLink) } private func isSimplexLink(_ link: String) -> Bool { - link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") + link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") || link.starts(with: "simplex:/") } private func cancelLinkPreview() { - if let pendingLink = pendingLinkUrl?.absoluteString { + if let pendingLink = pendingLinkUrl { cancelledLinks.insert(pendingLink) } - if let uri = composeState.linkPreview?.uri.absoluteString { + if let uri = composeState.linkPreview?.uri { cancelledLinks.insert(uri) } pendingLinkUrl = nil composeState = composeState.copy(preview: .noPreview) } - private func loadLinkPreview(_ url: URL) { - if pendingLinkUrl == url { + private func loadLinkPreview(_ urlStr: String) { + if pendingLinkUrl == urlStr, let url = URL(string: urlStr) { composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) getLinkPreview(url: url) { linkPreview in - if let linkPreview, pendingLinkUrl == url { + if let linkPreview, pendingLinkUrl == urlStr { + privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5 composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) } else { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1516,3 +1535,32 @@ struct ComposeView: View { cancelledLinks = [] } } + +func sanitizeMessage(_ parsedMsg: [FormattedText]) -> (message: String, parsedMsg: [FormattedText], sanitizedPos: Int?) { + var pos: Int = 0 + var updatedMsg = "" + var sanitizedPos: Int? = nil + let updatedParsedMsg = parsedMsg.map { ft in + var updated = ft + switch ft.format { + case .uri: + if let sanitized = parseSanitizeUri(ft.text)?.uriInfo?.sanitized { + updated = FormattedText(text: sanitized, format: .uri) + pos += updated.text.count + sanitizedPos = pos + } + case let .hyperLink(text, uri): + if let sanitized = parseSanitizeUri(uri)?.uriInfo?.sanitized { + let updatedText = if let text { "[\(text)](\(sanitized))" } else { sanitized } + updated = FormattedText(text: updatedText, format: .hyperLink(showText: text, linkUri: sanitized)) + pos += updated.text.count + sanitizedPos = pos + } + default: + pos += ft.text.count + } + updatedMsg += updated.text + return updated + } + return (message: updatedMsg, parsedMsg: updatedParsedMsg, sanitizedPos: sanitizedPos) +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index 9f453f1aa3..0450bd439c 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -629,7 +629,7 @@ struct ChatListSearchBar: View { } else { if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue searchFocussed = false - if case let .simplexLink(linkType, _, smpHosts) = link.format { + if case let .simplexLink(_, linkType, _, smpHosts) = link.format { ignoreSearchTextChange = true searchText = simplexLinkText(linkType, smpHosts) } diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index c009bf0d35..2e3119a8b8 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -381,7 +381,7 @@ struct ContactsListSearchBar: View { } else { if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue searchFocussed = false - if case let .simplexLink(linkType, _, smpHosts) = link.format { + if case let .simplexLink(_, linkType, _, smpHosts) = link.format { ignoreSearchTextChange = true searchText = simplexLinkText(linkType, smpHosts) } diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 54454b7cef..6df2d5422e 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -14,6 +14,7 @@ struct DeveloperView: View { @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false @State private var hintsUnchanged = hintDefaultsUnchanged() + @State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @Environment(\.colorScheme) var colorScheme @@ -65,6 +66,21 @@ struct DeveloperView: View { Text("Developer options") } } + Section("Deprecated options") { + settingsRow("link", color: theme.colors.secondary) { + Picker("SimpleX links", selection: $simplexLinkMode) { + ForEach( + SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode]) + ) { mode in + Text(mode.text) + } + } + } + .frame(height: 36) + .onChange(of: simplexLinkMode) { mode in + privacySimplexLinkModeDefault.set(mode) + } + } } } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index 06fe20a3fd..48c2efe0ac 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -14,11 +14,11 @@ struct PrivacySettings: View { @EnvironmentObject var theme: AppTheme @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true + @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = true @AppStorage(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_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @@ -75,8 +75,12 @@ struct PrivacySettings: View { Toggle("Send link previews", isOn: $useLinkPreviews) .onChange(of: useLinkPreviews) { linkPreviews in privacyLinkPreviewsGroupDefault.set(linkPreviews) + privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5 } } + settingsRow("link", color: theme.colors.secondary) { + Toggle("Remove link tracking", isOn: $privacySanitizeLinks) + } settingsRow("message", color: theme.colors.secondary) { Toggle("Show last messages", isOn: $showChatPreviews) } @@ -89,19 +93,6 @@ struct PrivacySettings: View { m.draftChatId = nil } } - settingsRow("link", color: theme.colors.secondary) { - Picker("SimpleX links", selection: $simplexLinkMode) { - ForEach( - SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode]) - ) { mode in - Text(mode.text) - } - } - } - .frame(height: 36) - .onChange(of: simplexLinkMode) { mode in - privacySimplexLinkModeDefault.set(mode) - } } header: { Text("Chats") .foregroundColor(theme.colors.secondary) diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index bfa410ca8e..00a8ea67ae 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -2563,6 +2563,10 @@ swipe action Потвърждениe за доставка! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Описание @@ -4289,7 +4293,7 @@ More improvements are coming soon! Invalid link Невалиден линк - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5460,10 +5464,18 @@ Requires compatible VPN. Отвори конзолата authentication reason + + Open clean link + alert action + Open conditions No comment provided by engineer. + + Open full link + alert action + Open group Отвори група @@ -6193,6 +6205,10 @@ swipe action Remove image No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Острани член diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 87524b09ab..1f32d7010e 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2455,6 +2455,10 @@ swipe action Potvrzení o doručení! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Popis @@ -4128,7 +4132,7 @@ More improvements are coming soon! Invalid link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5267,10 +5271,18 @@ Vyžaduje povolení sítě VPN. Otevřete konzolu chatu authentication reason + + Open clean link + alert action + Open conditions No comment provided by engineer. + + Open full link + alert action + Open group new chat action @@ -5976,6 +5988,10 @@ swipe action Remove image No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Odstranit člena diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index f42e5d20ce..6f294d82d5 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2680,6 +2680,10 @@ swipe action Empfangsbestätigungen! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Beschreibung @@ -4517,7 +4521,7 @@ Weitere Verbesserungen sind bald verfügbar! Invalid link Ungültiger Link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5770,11 +5774,19 @@ Dies erfordert die Aktivierung eines VPNs. Chat-Konsole öffnen authentication reason + + Open clean link + alert action + Open conditions Nutzungsbedingungen öffnen No comment provided by engineer. + + Open full link + alert action + Open group Gruppe öffnen @@ -6564,6 +6576,10 @@ swipe action Bild entfernen No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Mitglied entfernen diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 05b9b9bc9d..7a6efcfba8 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2684,6 +2684,11 @@ swipe action Delivery receipts! No comment provided by engineer. + + Deprecated options + Deprecated options + No comment provided by engineer. + Description Description @@ -4522,7 +4527,7 @@ More improvements are coming soon! Invalid link Invalid link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5777,11 +5782,21 @@ Requires compatible VPN. Open chat console authentication reason + + Open clean link + Open clean link + alert action + Open conditions Open conditions No comment provided by engineer. + + Open full link + Open full link + alert action + Open group Open group @@ -6572,6 +6587,11 @@ swipe action Remove image No comment provided by engineer. + + Remove link tracking + Remove link tracking + No comment provided by engineer. + Remove member Remove member diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 7f33515577..71415bb02d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -2680,6 +2680,10 @@ swipe action ¡Confirmación de entrega! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Descripción @@ -4517,7 +4521,7 @@ More improvements are coming soon! Invalid link Enlace no válido - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5770,11 +5774,19 @@ Requiere activación de la VPN. Abrir consola de Chat authentication reason + + Open clean link + alert action + Open conditions Abrir condiciones No comment provided by engineer. + + Open full link + alert action + Open group Grupo abierto @@ -6564,6 +6576,10 @@ swipe action Eliminar imagen No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Expulsar miembro diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 4b42238a22..de08d5f39e 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2426,6 +2426,10 @@ swipe action Toimituskuittaukset! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Kuvaus @@ -4096,7 +4100,7 @@ More improvements are coming soon! Invalid link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5233,10 +5237,18 @@ Edellyttää VPN:n sallimista. Avaa keskustelukonsoli authentication reason + + Open clean link + alert action + Open conditions No comment provided by engineer. + + Open full link + alert action + Open group new chat action @@ -5942,6 +5954,10 @@ swipe action Remove image No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Poista jäsen diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 36c1fdfc91..bfc09b29ec 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -2661,6 +2661,10 @@ swipe action Justificatifs de réception ! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Description @@ -4474,7 +4478,7 @@ D'autres améliorations sont à venir ! Invalid link Lien invalide - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5694,11 +5698,19 @@ Nécessite l'activation d'un VPN. Ouvrir la console du chat authentication reason + + Open clean link + alert action + Open conditions Ouvrir les conditions No comment provided by engineer. + + Open full link + alert action + Open group Ouvrir le groupe @@ -6467,6 +6479,10 @@ swipe action Enlever l'image No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Retirer le membre diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index af02421c01..a23021b67e 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -2680,6 +2680,10 @@ swipe action Kézbesítési jelentések! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Leírás @@ -4517,7 +4521,7 @@ További fejlesztések hamarosan! Invalid link Érvénytelen hivatkozás - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5770,11 +5774,19 @@ VPN engedélyezése szükséges. Csevegési konzol megnyitása authentication reason + + Open clean link + alert action + Open conditions Feltételek megnyitása No comment provided by engineer. + + Open full link + alert action + Open group Csoport megnyitása @@ -6564,6 +6576,10 @@ swipe action Kép eltávolítása No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Eltávolítás diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index a54056dc4d..7f789cb85c 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -2680,6 +2680,10 @@ swipe action Ricevute di consegna! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Descrizione @@ -4517,7 +4521,7 @@ Altri miglioramenti sono in arrivo! Invalid link Link non valido - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5770,11 +5774,19 @@ Richiede l'attivazione della VPN. Apri la console della chat authentication reason + + Open clean link + alert action + Open conditions Apri le condizioni No comment provided by engineer. + + Open full link + alert action + Open group Apri gruppo @@ -6564,6 +6576,10 @@ swipe action Rimuovi immagine No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Rimuovi membro diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 44db30b201..2c34ae3499 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2504,6 +2504,10 @@ swipe action 配信通知! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description 説明 @@ -4177,7 +4181,7 @@ More improvements are coming soon! Invalid link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5318,10 +5322,18 @@ VPN を有効にする必要があります。 チャットのコンソールを開く authentication reason + + Open clean link + alert action + Open conditions No comment provided by engineer. + + Open full link + alert action + Open group new chat action @@ -6027,6 +6039,10 @@ swipe action Remove image No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member メンバーを除名する diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 495fd3ee1a..4079a6cd9c 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2670,6 +2670,10 @@ swipe action Ontvangstbewijzen! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Beschrijving @@ -4498,7 +4502,7 @@ Binnenkort meer verbeteringen! Invalid link Ongeldige link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5743,11 +5747,19 @@ Vereist het inschakelen van VPN. Chat console openen authentication reason + + Open clean link + alert action + Open conditions Open voorwaarden No comment provided by engineer. + + Open full link + alert action + Open group Open groep @@ -6530,6 +6542,10 @@ swipe action Verwijder afbeelding No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Lid verwijderen diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 224bc41e42..a0a929ac63 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -2630,6 +2630,10 @@ swipe action Potwierdzenia dostawy! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Opis @@ -4406,7 +4410,7 @@ More improvements are coming soon! Invalid link Nieprawidłowy link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5607,10 +5611,18 @@ Wymaga włączenia VPN. Otwórz konsolę czatu authentication reason + + Open clean link + alert action + Open conditions No comment provided by engineer. + + Open full link + alert action + Open group Grupa otwarta @@ -6373,6 +6385,10 @@ swipe action Usuń obraz No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Usuń członka diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 00f91be200..d4f37a9b35 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2680,6 +2680,10 @@ swipe action Отчёты о доставке! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Описание @@ -4516,7 +4520,7 @@ More improvements are coming soon! Invalid link Ошибка ссылки - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5769,11 +5773,19 @@ Requires compatible VPN. Открыть консоль authentication reason + + Open clean link + alert action + Open conditions Открыть условия No comment provided by engineer. + + Open full link + alert action + Open group Открыть группу @@ -6563,6 +6575,10 @@ swipe action Удалить изображение No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Удалить члена группы diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 2c514afc72..d2f4b979d8 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2414,6 +2414,10 @@ swipe action ใบตอบรับการจัดส่ง! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description คำอธิบาย @@ -4080,7 +4084,7 @@ More improvements are coming soon! Invalid link - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5212,10 +5216,18 @@ Requires compatible VPN. เปิดคอนโซลการแชท authentication reason + + Open clean link + alert action + Open conditions No comment provided by engineer. + + Open full link + alert action + Open group new chat action @@ -5919,6 +5931,10 @@ swipe action Remove image No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member ลบสมาชิกออก diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 3e94c4acd2..0e758dc1d7 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2680,6 +2680,10 @@ swipe action Mesaj gönderildi bilgisi! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Açıklama @@ -4516,7 +4520,7 @@ Daha fazla iyileştirme yakında geliyor! Invalid link Geçersiz bağlantı - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5767,11 +5771,19 @@ VPN'nin etkinleştirilmesi gerekir. Sohbet konsolunu aç authentication reason + + Open clean link + alert action + Open conditions Açık koşullar No comment provided by engineer. + + Open full link + alert action + Open group Grubu aç @@ -6561,6 +6573,10 @@ swipe action Resmi kaldır No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Kişiyi sil diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index d87f1cc613..555baafd69 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2679,6 +2679,10 @@ swipe action Квитанції про доставку! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description Опис @@ -4515,7 +4519,7 @@ More improvements are coming soon! Invalid link Невірне посилання - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5767,11 +5771,19 @@ Requires compatible VPN. Відкрийте консоль чату authentication reason + + Open clean link + alert action + Open conditions Відкриті умови No comment provided by engineer. + + Open full link + alert action + Open group Відкрита група @@ -6561,6 +6573,10 @@ swipe action Видалити зображення No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member Видалити учасника diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index f8767b14e4..562b623217 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -2662,6 +2662,10 @@ swipe action 送达回执! No comment provided by engineer. + + Deprecated options + No comment provided by engineer. + Description 描述 @@ -4487,7 +4491,7 @@ More improvements are coming soon! Invalid link 无效链接 - No comment provided by engineer. + alert title Invalid migration confirmation @@ -5728,11 +5732,19 @@ Requires compatible VPN. 打开聊天控制台 authentication reason + + Open clean link + alert action + Open conditions 打开条款 No comment provided by engineer. + + Open full link + alert action + Open group 打开群 @@ -6497,6 +6509,10 @@ swipe action 移除图片 No comment provided by engineer. + + Remove link tracking + No comment provided by engineer. + Remove member 删除成员 diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index 12a775f85c..5080cf2040 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -390,7 +390,7 @@ enum SharedContent { switch self { case let .image(preview, _): .image(text: comment, image: preview) case let .movie(preview, duration, _): .video(text: comment, image: preview, duration: duration) - case let .url(preview): .link(text: preview.uri.absoluteString + (comment == "" ? "" : "\n" + comment), preview: preview) + case let .url(preview): .link(text: preview.uri + (comment == "" ? "" : "\n" + comment), preview: preview) case .text: .text(comment) case .data: .file(comment) } @@ -464,12 +464,13 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result ParsedUri? { + var c = s.cString(using: .utf8)! + if let cjson = chat_parse_uri(&c) { + if let d = dataFromCString(cjson) { + do { + return try jsonDecoder.decode(ParsedUri.self, from: d) + } catch { + logger.error("parseSanitizeUri jsonDecoder.decode error: \(error.localizedDescription)") + } + } + } + return nil +} + +public struct ParsedUri: Decodable { + public var uriInfo: UriInfo? + public var parseError: String +} + +public struct UriInfo: Decodable { + public var scheme: String + public var sanitized: String? +} + @inline(__always) public func fromCString(_ c: UnsafeMutablePointer) -> String { let s = String.init(cString: c) diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 5eed01a2a2..47932397fc 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -27,6 +27,8 @@ let GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED = "appLocalAuthEnabled" public let GROUP_DEFAULT_ALLOW_SHARE_EXTENSION = "allowShareExtension" // replaces DEFAULT_PRIVACY_LINK_PREVIEWS let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" +public let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "privacyLinkPreviewsShowAlert" +public let GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS = "privacySanitizeLinks" // This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used @@ -95,6 +97,8 @@ public func registerGroupDefaults() { GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED: true, GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false, GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true, + GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true, + GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: true, GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true, GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false, GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true, @@ -222,6 +226,8 @@ public let allowShareExtensionGroupDefault = BoolDefault(defaults: groupDefaults public let privacyLinkPreviewsGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS) +public let privacyLinkPreviewsShowAlertGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT) + // This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index f868b787ee..7a70c6b664 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -4611,6 +4611,11 @@ public struct FormattedText: Decodable, Hashable { public var text: String public var format: Format? + public init(text: String, format: Format? = nil) { + self.text = text + self.format = format + } + public static func plain(_ text: String) -> [FormattedText] { text.isEmpty ? [] @@ -4620,6 +4625,14 @@ public struct FormattedText: Decodable, Hashable { public var isSecret: Bool { if case .secret = format { true } else { false } } + + public var linkUri: String? { + switch format { + case .uri: text + case let .hyperLink(_, linkUri): linkUri + default: nil + } + } } public enum Format: Decodable, Equatable, Hashable { @@ -4630,7 +4643,8 @@ public enum Format: Decodable, Equatable, Hashable { case secret case colored(color: FormatColor) case uri - case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) + case hyperLink(showText: String?, linkUri: String) + case simplexLink(showText: String?, linkType: SimplexLinkType, simplexUri: String, smpHosts: [String]) case command(commandStr: String) case mention(memberName: String) case email @@ -4748,14 +4762,14 @@ extension ReportReason: Decodable { // Struct to use with simplex API public struct LinkPreview: Codable, Equatable, Hashable { - public init(uri: URL, title: String, description: String = "", image: String) { + public init(uri: String, title: String, description: String = "", image: String) { self.uri = uri self.title = title self.description = description self.image = image } - public var uri: URL + public var uri: String public var title: String // TODO remove once optional in haskell public var description: String = "" diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index be43158bc1..c70ca5edd8 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -446,7 +446,7 @@ public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { let resized = resizeImageToStrSizeSync(image, maxDataSize: 14000), let title = metadata.title, let uri = metadata.originalURL { - linkPreview = LinkPreview(uri: uri, title: title, image: resized) + linkPreview = LinkPreview(uri: uri.absoluteString, title: title, image: resized) } } cb(linkPreview) diff --git a/apps/ios/SimpleXChat/SimpleX.h b/apps/ios/SimpleXChat/SimpleX.h index 8a443017e1..66f570f1b6 100644 --- a/apps/ios/SimpleXChat/SimpleX.h +++ b/apps/ios/SimpleXChat/SimpleX.h @@ -24,6 +24,7 @@ extern char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_parse_markdown(char *str); extern char *chat_parse_server(char *str); +extern char *chat_parse_uri(char *str); extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_valid_name(char *name); extern int chat_json_length(char *str); diff --git a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c index d8fa2c65a7..cfbed65c76 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/android/simplex-api.c @@ -64,6 +64,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); +extern char *chat_parse_uri(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); extern int chat_json_length(const char *str); @@ -146,6 +147,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str) { + const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE); + jstring res = (*env)->NewStringUTF(env, chat_parse_uri(_str)); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) { const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE); diff --git a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c index 2614b1a561..076e323ca6 100644 --- a/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c +++ b/apps/multiplatform/common/src/commonMain/cpp/desktop/simplex-api.c @@ -37,6 +37,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait); extern char *chat_parse_markdown(const char *str); extern char *chat_parse_server(const char *str); +extern char *chat_parse_uri(const char *str); extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_valid_name(const char *name); extern int chat_json_length(const char *str); @@ -156,6 +157,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass cla return res; } +JNIEXPORT jstring JNICALL +Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, jclass clazz, jstring str) { + const char *_str = encode_to_utf8_chars(env, str); + jstring res = decode_to_utf8_string(env, chat_parse_uri(_str)); + (*env)->ReleaseStringUTFChars(env, str, _str); + return res; +} + JNIEXPORT jstring JNICALL Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) { const char *_pwd = encode_to_utf8_chars(env, pwd); diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 962bd82dd3..1d00d7cdb0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -4369,19 +4369,12 @@ sealed class MsgChatLink { @Serializable class FormattedText(val text: String, val format: Format? = null) { - fun link(mode: SimplexLinkMode): String? = when (format) { - is Format.Uri -> if (text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)) text else "https://$text" - is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri - is Format.Email -> "mailto:$text" - is Format.Phone -> "tel:$text" - else -> null - } - - fun viewText(mode: SimplexLinkMode): String = - if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text - - fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List): String = - "${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" + val linkUri: String? get() = + when (format) { + is Format.Uri -> text + is Format.HyperLink -> format.linkUri + else -> null + } companion object { fun plain(text: String): List = if (text.isEmpty()) emptyList() else listOf(FormattedText(text)) @@ -4397,7 +4390,13 @@ sealed class Format { @Serializable @SerialName("secret") class Secret: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() - @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() + @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() + @Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List): Format() { + val simplexLinkText: String get() = + "${linkType.description} $viaHosts" + val viaHosts: String get() = + "(${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})" + } @Serializable @SerialName("command") class Command(val commandStr: String): Format() @Serializable @SerialName("mention") class Mention(val memberName: String): Format() @Serializable @SerialName("email") class Email: Format() @@ -4412,6 +4411,7 @@ sealed class Format { is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor) is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle + is HyperLink -> linkStyle is SimplexLink -> linkStyle is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace) is Mention -> SpanStyle(fontWeight = FontWeight.Medium) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index b23869849d..3e56ef3a2e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -104,6 +104,9 @@ class AppPreferences { val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true) val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true) val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true) + val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true) + val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, true) + // TODO remove val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } } val simplexLinkMode: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default) val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true) @@ -369,7 +372,9 @@ class AppPreferences { private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages" private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline" private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews" - private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" + private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert" + private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks" + private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode" private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews" private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft" @@ -4629,6 +4634,19 @@ data class ParsedServerAddress ( var parseError: String ) +fun parseSanitizeUri(s: String): ParsedUri? { + val parsed = chatParseUri(s) + return runCatching { json.decodeFromString(ParsedUri.serializer(), parsed) } + .onFailure { Log.d(TAG, "parseSanitizeUri decode error: $it") } + .getOrNull() +} + +@Serializable +data class ParsedUri(val uriInfo: UriInfo?, val parseError: String) + +@Serializable +data class UriInfo(val scheme: String, val sanitized: String?) + @Serializable data class NetCfg( val socksProxy: String?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index 9d8a699775..35194ba1e6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -28,6 +28,7 @@ external fun chatRecvMsg(ctrl: ChatCtrl): String external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String external fun chatParseMarkdown(str: String): String external fun chatParseServer(str: String): String +external fun chatParseUri(str: String): String external fun chatPasswordHash(pwd: String, salt: String): String external fun chatValidName(name: String): String external fun chatJsonLength(str: String): Int diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 5f99ac77d6..fdc6a3fed5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -356,16 +356,21 @@ fun ComposeView( fun isSimplexLink(link: String): Boolean = link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - fun getSimplexLink(parsedMsg: List?): Pair { + fun getMessageLinks(parsedMsg: List?): Pair { if (parsedMsg == null) return null to false - val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink } - return link?.text to simplexLink + for (ft in parsedMsg) { + val link = ft.linkUri + if (link != null && !cancelledLinks.contains(link) && !isSimplexLink(link)) { + return link to simplexLink + } + } + return null to simplexLink } val linkUrl = rememberSaveable { mutableStateOf(null) } // default value parsed because of draft - val hasSimplexLink = rememberSaveable { mutableStateOf(getSimplexLink(parseToMarkdown(composeState.value.message.text)).second) } + val hasSimplexLink = rememberSaveable { mutableStateOf(getMessageLinks(parseToMarkdown(composeState.value.message.text)).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() @@ -382,6 +387,7 @@ fun ComposeView( if (wait != null) delay(wait) val lp = getLinkPreview(url) if (lp != null && pendingLinkUrl.value == url) { + chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) // to avoid showing alert to current users, show alert in v6.5 composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp)) pendingLinkUrl.value = null } else if (pendingLinkUrl.value == url) { @@ -394,7 +400,7 @@ fun ComposeView( fun showLinkPreview(parsedMessage: List?) { prevLinkUrl.value = linkUrl.value - val linkParsed = getSimplexLink(parsedMessage) + val linkParsed = getMessageLinks(parsedMessage) linkUrl.value = linkParsed.first hasSimplexLink.value = linkParsed.second val url = linkUrl.value @@ -501,7 +507,7 @@ fun ComposeView( return when (val composePreview = composeState.value.preview) { is ComposePreview.CLinkPreview -> { val parsedMsg = parseToMarkdown(msgText) - val url = getSimplexLink(parsedMsg).first + val url = getMessageLinks(parsedMsg).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -861,9 +867,53 @@ fun ComposeView( } } + fun sanitizeMessage(parsedMsg: List): Triple, Int?> { + var pos = 0 + var updatedMsg = "" + var sanitizedPos: Int? = null + val updatedParsedMsg = parsedMsg.map { ft -> + var updated = ft + when(ft.format) { + is Format.Uri -> { + val sanitized = parseSanitizeUri(ft.text)?.uriInfo?.sanitized + if (sanitized != null) { + updated = FormattedText(text = sanitized, format = Format.Uri()) + pos += updated.text.count() + sanitizedPos = pos + } + } + is Format.HyperLink -> { + val sanitized = parseSanitizeUri(ft.format.linkUri)?.uriInfo?.sanitized + if (sanitized != null) { + val updatedText = if (ft.format.showText == null) sanitized else "[${ft.format.showText}]($sanitized)" + updated = FormattedText(text = updatedText, format = Format.HyperLink(showText = ft.format.showText, linkUri = sanitized)) + pos += updated.text.count() + sanitizedPos = pos + } + } + else -> + pos += ft.text.count() + } + updatedMsg += updated.text + updated + } + return Triple(updatedMsg, updatedParsedMsg, sanitizedPos) + } + fun onMessageChange(s: ComposeMessage) { - val parsedMessage = parseToMarkdown(s.text) - composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text)) + var parsedMessage = parseToMarkdown(s.text) + if (chatModel.controller.appPrefs.privacySanitizeLinks.state.value && parsedMessage != null) { + val (updatedMsg, updatedParsedMsg, sanitizedPos) = sanitizeMessage(parsedMessage) + if (sanitizedPos == null) { + composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage) + } else { + val message = if (sanitizedPos < s.selection.start) s.copy(text = updatedMsg) else ComposeMessage(updatedMsg, TextRange(sanitizedPos, sanitizedPos)) + composeState.value = composeState.value.copy(message = message, parsedMessage = updatedParsedMsg) + parsedMessage = updatedParsedMsg + } + } else { + composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text)) + } if (isShortEmoji(s.text)) { textStyle.value = if (s.text.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { @@ -876,7 +926,7 @@ fun ComposeView( hasSimplexLink.value = false } } else if (s.text.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { - hasSimplexLink.value = getSimplexLink(parsedMessage).second + hasSimplexLink.value = getMessageLinks(parsedMessage).second } else { hasSimplexLink.value = false } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 291eedb4ee..6a6485bc42 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -1,5 +1,8 @@ package chat.simplex.common.views.chat.item +import SectionItemView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.material.MaterialTheme @@ -12,15 +15,15 @@ import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.AnnotatedString.Range -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.style.* import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors import chat.simplex.common.views.helpers.* +import chat.simplex.res.* import kotlinx.coroutines.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) @@ -145,55 +148,102 @@ fun MarkdownText ( if (prefix != null) append(prefix) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) - else if (toggleSecrets && ft.format is Format.Secret) { - val ftStyle = ft.format.style - hasSecrets = true - val key = i.toString() - withAnnotation(tag = "SECRET", annotation = key) { - if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } - } - } else if (ft.format is Format.Mention) { - val mention = mentions?.get(ft.format.memberName) - - if (mention != null) { - if (mention.memberRef != null) { - val displayName = mention.memberRef.displayName - val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { - displayName - } else { - "${mention.memberRef.localAlias} ($displayName)" + else when(ft.format) { + is Format.Bold -> withStyle(ft.format.style) { append(ft.text) } + is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } + is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } + is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } + is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } + is Format.Secret -> { + val ftStyle = ft.format.style + if (toggleSecrets) { + hasSecrets = true + val key = i.toString() + withAnnotation(tag = "SECRET", annotation = key) { + if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) } } - val mentionStyle = if (mention.memberId == userMemberId) ft.format.style.copy(color = MaterialTheme.colors.primary) else ft.format.style - - withStyle(mentionStyle) { append(mentionText(name)) } } else { - withStyle( ft.format.style) { append(mentionText(ft.format.memberName)) } - } - } else { - append(ft.text) - } - } else if (ft.format is Format.Command) { - if (sendCommandMsg == null) { - append(ft.text) - } else { - hasCommands = true - val ftStyle = ft.format.style - val cmd = ft.format.commandStr - withAnnotation(tag = "COMMAND", annotation = cmd) { - withStyle(ftStyle) { append("/$cmd") } + withStyle(ftStyle) { append(ft.text) } } } - } else { - val link = ft.link(linkMode) - if (link != null) { + is Format.Mention -> { + val mention = mentions?.get(ft.format.memberName) + if (mention != null) { + val ftStyle = ft.format.style + if (mention.memberRef != null) { + val displayName = mention.memberRef.displayName + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) { + displayName + } else { + "${mention.memberRef.localAlias} ($displayName)" + } + val mentionStyle = if (mention.memberId == userMemberId) ftStyle.copy(color = MaterialTheme.colors.primary) else ftStyle + withStyle(mentionStyle) { append(mentionText(name)) } + } else { + withStyle(ftStyle) { append(mentionText(ft.format.memberName)) } + } + } else { + append(ft.text) + } + } + is Format.Command -> + if (sendCommandMsg == null) { + append(ft.text) + } else { + hasCommands = true + val ftStyle = ft.format.style + val cmd = ft.format.commandStr + withAnnotation(tag = "COMMAND", annotation = cmd) { + withStyle(ftStyle) { append("/$cmd") } + } + } + is Format.Uri -> { hasLinks = true - val ftStyle = ft.format.style - withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) { - withStyle(ftStyle) { append(ft.viewText(linkMode)) } + val ftStyle = Format.linkStyle + val s = ft.text + val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s" + withAnnotation(tag = "WEB_URL", annotation = link) { + withStyle(ftStyle) { append(ft.text) } } - } else { - withStyle(ft.format.style) { append(ft.text) } } + is Format.HyperLink -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) { + withStyle(ftStyle) { append(ft.format.showText ?: ft.text) } + } + } + is Format.SimplexLink -> { + hasLinks = true + val ftStyle = Format.linkStyle + val link = + if (linkMode == SimplexLinkMode.BROWSER && ft.format.showText == null && !ft.text.startsWith("[")) ft.text + else ft.format.simplexUri + val t = ft.format.showText ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + withAnnotation(tag = "SIMPLEX_URL", annotation = link) { + if (t == null) { + withStyle(ftStyle) { append(ft.text) } + } else { + withStyle(ftStyle) { append("$t ") } + withStyle(ftStyle.copy(fontStyle = FontStyle.Italic)) { append(ft.format.viaHosts) } + } + } + } + is Format.Email -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "OTHER_URL", annotation = "mailto:${ft.text}") { + withStyle(ftStyle) { append(ft.text) } + } + } + is Format.Phone -> { + hasLinks = true + val ftStyle = Format.linkStyle + withAnnotation(tag = "OTHER_URL", annotation = "tel:${ft.text}") { + withStyle(ftStyle) { append(ft.text) } + } + } + is Format.Unknown -> append(ft.text) } } if (meta?.isLive == true) { @@ -209,10 +259,12 @@ fun MarkdownText ( ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { - annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } - annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) } + val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> + annotatedText.getStringAnnotations(tag, start = offset, end = offset).firstOrNull()?.let(f) + } + withAnnotation("WEB_URL") { a -> onLinkLongClick(a.item) } + withAnnotation("SIMPLEX_URL") { a -> onLinkLongClick(a.item) } + withAnnotation("OTHER_URL") { a -> onLinkLongClick(a.item) } } }, onClick = { offset -> @@ -220,37 +272,33 @@ fun MarkdownText ( annotatedText.getStringAnnotations(tag, start = offset, end = offset).firstOrNull()?.let(f) } if (hasLinks && uriHandler != null) { - withAnnotation("URL") { a -> - try { - uriHandler.openUri(a.item) - } catch (e: Exception) { - // It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch - // `tel:` scheme in url installed on a device (no phone app or contacts, maybe) - Log.e(TAG, "Open url: ${e.stackTraceToString()}") - } - } + withAnnotation("WEB_URL") { a -> openBrowserAlert(a.item, uriHandler) } + withAnnotation("OTHER_URL") { a -> safeOpenUri(a.item, uriHandler) } withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) } - } else if (hasSecrets) { + } + if (hasSecrets) { withAnnotation("SECRET") { a -> val key = a.item showSecrets[key] = !(showSecrets[key] ?: false) } - } else if (hasCommands && sendCommandMsg != null) { + } + if (hasCommands && sendCommandMsg != null) { withAnnotation("COMMAND") { a -> sendCommandMsg("/${a.item}") } } }, onHover = { offset -> val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } icon.value = - if (hasAnnotation("URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { + if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) { PointerIcon.Hand } else { PointerIcon.Default } }, shouldConsumeEvent = { offset -> - annotatedText.hasStringAnnotations(tag = "URL", start = offset, end = offset) + annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) + || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) } ) } else { @@ -319,6 +367,74 @@ fun ClickableText( ) } +fun openBrowserAlert(uri: String, uriHandler: UriHandler) { + val (res, err) = sanitizeUri(uri) + if (res == null) { + showInvalidLinkAlert(uri, err) + } else { + val message = if (uri.count() > 160) uri.substring(0, 159) + "…" else uri + val sanitizedUri = res.second + if (sanitizedUri == null) { + AlertManager.shared.showAlertDialog( + generalGetString(MR.strings.privacy_chat_list_open_web_link_question), + message, + confirmText = generalGetString(MR.strings.open_verb), + onConfirm = { safeOpenUri(uri, uriHandler) } + ) + } else { + AlertManager.shared.showAlertDialogButtonsColumn( + generalGetString(MR.strings.privacy_chat_list_open_web_link_question), + message, + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + safeOpenUri(uri, uriHandler) + }) { + Text(generalGetString(MR.strings.privacy_chat_list_open_full_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + SectionItemView({ + AlertManager.shared.hideAlert() + safeOpenUri(sanitizedUri, uriHandler) + }) { + Text(generalGetString(MR.strings.privacy_chat_list_open_clean_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) + } + } +} + +fun safeOpenUri(uri: String, uriHandler: UriHandler) { + try { + uriHandler.openUri(uri) + } catch (e: Exception) { + // It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch + // `tel:` scheme in url installed on a device (no phone app or contacts, maybe) + Log.e(TAG, "Open url: ${e.stackTraceToString()}") + showInvalidLinkAlert(uri, error = e.message) + } +} + +fun showInvalidLinkAlert(uri: String, error: String? = null) { + val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri } + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message) +} + +fun sanitizeUri(s: String): Pair?, String?> { + val parsed = parseSanitizeUri(s) + return if (parsed?.uriInfo != null) { + (true to parsed.uriInfo.sanitized) to null + } else { + null to parsed?.parseError + } +} + private fun isRtl(s: CharSequence): Boolean { for (element in s) { val d = Character.getDirectionality(element) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 5e9bac515d..7b5be0c323 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -659,7 +659,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState // if SimpleX link is pasted, show connection dialogue hideKeyboard(view) if (link.format is Format.SimplexLink) { - val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts) + val linkText = link.format.simplexLinkText searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero) } searchShowingSimplexLink.value = true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 35681ff1d2..d5d6facafe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -298,37 +298,9 @@ fun ChatPreviewView( val uriHandler = LocalUriHandler.current when (mc) { is MsgContent.MCLink -> SmallContentPreview { - val linkClicksEnabled = remember { appPrefs.privacyChatListOpenLinks.state }.value != PrivacyChatListOpenLinksMode.NO - IconButton({ - when (appPrefs.privacyChatListOpenLinks.get()) { - PrivacyChatListOpenLinksMode.YES -> uriHandler.openUriCatching(mc.preview.uri) - PrivacyChatListOpenLinksMode.NO -> defaultClickAction() - PrivacyChatListOpenLinksMode.ASK -> AlertManager.shared.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.privacy_chat_list_open_web_link_question), - text = mc.preview.uri, - buttons = { - Column { - if (chatModel.chatId.value != chat.id) { - SectionItemView({ - AlertManager.shared.hideAlert() - defaultClickAction() - }) { - Text(stringResource(MR.strings.open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - SectionItemView({ - AlertManager.shared.hideAlert() - uriHandler.openUriCatching(mc.preview.uri) - } - ) { - Text(stringResource(MR.strings.privacy_chat_list_open_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - } - } - ) - } - }, - if (linkClicksEnabled) Modifier.desktopPointerHoverIconHand() else Modifier, + IconButton( + { openBrowserAlert(mc.preview.uri, uriHandler) }, + Modifier.desktopPointerHoverIconHand(), ) { Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index b1c5caedc9..434cb6ce27 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -63,7 +63,7 @@ private suspend fun planAndConnectTask( val (connectionLink, connectionPlan) = result val link = strHasSingleSimplexLink(shortOrFullLink.trim()) val linkText = if (link?.format is Format.SimplexLink) - "

${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}" + "

${link.format.simplexLinkText}" else "" when (connectionPlan) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 1cb1903a14..ef6e426141 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -519,10 +519,8 @@ private fun ContactsSearchBar( // if SimpleX link is pasted, show connection dialogue hideKeyboard(view) if (link.format is Format.SimplexLink) { - val linkText = - link.simplexLinkText(link.format.linkType, link.format.smpHosts) - searchText.value = - searchText.value.copy(linkText, selection = TextRange.Zero) + val linkText = link.format.simplexLinkText + searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero) } searchShowingSimplexLink.value = true searchChatFilteredBySimplexLink.value = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index c5a4ae5f70..dcb71a552d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -2,18 +2,10 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced -import SectionSpacer import SectionTextFooter import SectionView -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler -import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* import dev.icerock.moko.resources.compose.painterResource @@ -67,7 +59,15 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) } } - SectionBottomSpacer() + SectionDividerSpaced(maxTopPadding = true) + SectionView(stringResource(MR.strings.deprecated_options_section).uppercase()) { + val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode + SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = { + simplexLinkMode.set(it) + chatModel.simplexLinkMode.value = it + }) + SectionBottomSpacer() + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 915119fa64..2771b5ac62 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -56,16 +56,22 @@ fun PrivacySettingsView( setPerformLA: (Boolean) -> Unit ) { ColumnWithScrollBar { - val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode AppBarTitle(stringResource(MR.strings.your_privacy)) PrivacyDeviceSection(showSettingsModal, setPerformLA) SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_chats)) { - SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews) - ChatListLinksOptions(appPrefs.privacyChatListOpenLinks.state, onSelected = { - appPrefs.privacyChatListOpenLinks.set(it) - }) + SettingsPreferenceItem( + painterResource(MR.images.ic_travel_explore), + stringResource(MR.strings.send_link_previews), + chatModel.controller.appPrefs.privacyLinkPreviews, + onChange = { _ -> chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) } // to avoid showing alert to current users, show alert in v6.5 + ) + SettingsPreferenceItem( + painterResource(MR.images.ic_link), + stringResource(MR.strings.sanitize_links_toggle), + chatModel.controller.appPrefs.privacySanitizeLinks + ) SettingsPreferenceItem( painterResource(MR.images.ic_chat_bubble), stringResource(MR.strings.privacy_show_last_messages), @@ -84,10 +90,6 @@ fun PrivacySettingsView( chatModel.draftChatId.value = null } }) - SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = { - simplexLinkMode.set(it) - chatModel.simplexLinkMode.value = it - }) } SectionDividerSpaced() @@ -218,27 +220,7 @@ fun PrivacySettingsView( } @Composable -private fun ChatListLinksOptions(state: State, onSelected: (PrivacyChatListOpenLinksMode) -> Unit) { - val values = remember { - PrivacyChatListOpenLinksMode.entries.map { - when (it) { - PrivacyChatListOpenLinksMode.YES -> it to generalGetString(MR.strings.privacy_chat_list_open_links_yes) - PrivacyChatListOpenLinksMode.NO -> it to generalGetString(MR.strings.privacy_chat_list_open_links_no) - PrivacyChatListOpenLinksMode.ASK -> it to generalGetString(MR.strings.privacy_chat_list_open_links_ask) - } - } - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.privacy_chat_list_open_links), - values, - state, - icon = painterResource(MR.images.ic_open_in_new), - onSelected = onSelected - ) -} - -@Composable -private fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { +fun SimpleXLinkOptions(simplexLinkModeState: State, onSelected: (SimplexLinkMode) -> Unit) { val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL) val pickerValues = modeValues + if (modeValues.contains(simplexLinkModeState.value)) emptyList() else listOf(simplexLinkModeState.value) val values = remember { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 22cdd3a643..0b53b8d577 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1072,6 +1072,7 @@ Enable logs Database IDs and Transport isolation option. Developer options + Deprecated options Show internal errors Show slow API calls Shutdown? @@ -1368,6 +1369,7 @@ The app will ask to confirm downloads from unknown file servers (except .onion or when SOCKS proxy is enabled). Without Tor or VPN, your IP address will be visible to file servers. Send link previews + Remove link tracking Show last messages Message draft App data backup @@ -1434,6 +1436,8 @@ Ask Open web link? Open link + Open full link + Open clean link YOU diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 416fd1ca3a..74e761107f 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1979,8 +1979,14 @@ Colored: Uri: - type: "uri" +HyperLink: +- type: "hyperLink" +- showText: string? +- linkUri: string + SimplexLink: - type: "simplexLink" +- showText: string? - linkType: [SimplexLinkType](#simplexlinktype) - simplexUri: string - smpHosts: [string] diff --git a/flake.nix b/flake.nix index a5c4eb2a27..1520fb2ee3 100644 --- a/flake.nix +++ b/flake.nix @@ -382,6 +382,7 @@ "chat_migrate_init" "chat_parse_markdown" "chat_parse_server" + "chat_parse_uri" "chat_password_hash" "chat_read_file" "chat_recv_msg" @@ -489,6 +490,7 @@ "chat_migrate_init" "chat_parse_markdown" "chat_parse_server" + "chat_parse_uri" "chat_password_hash" "chat_read_file" "chat_recv_msg" diff --git a/libsimplex.dll.def b/libsimplex.dll.def index 2945d52e83..76e6f9f3ee 100644 --- a/libsimplex.dll.def +++ b/libsimplex.dll.def @@ -12,6 +12,7 @@ EXPORTS chat_recv_msg_wait chat_parse_markdown chat_parse_server + chat_parse_uri chat_password_hash chat_valid_name chat_json_length diff --git a/simplex-chat.cabal b/simplex-chat.cabal index fb1dc5e5fc..cbd569f8b6 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -298,6 +298,7 @@ library , tls >=1.9.0 && <1.10 , unliftio ==0.2.* , unliftio-core ==0.2.* + , uri-bytestring >=0.3.3.1 && <0.4 , uuid ==1.3.* , zip ==2.0.* , zstd ==0.1.3.* diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 67e38bab15..19e80e4339 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -1,6 +1,8 @@ {-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE MultiWayIf #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -12,11 +14,13 @@ module Simplex.Chat.Markdown where import Control.Applicative (optional, (<|>)) +import Control.Monad import Data.Aeson (FromJSON, ToJSON) import qualified Data.Aeson as J import qualified Data.Aeson.TH as JQ import Data.Attoparsec.Text (Parser) import qualified Data.Attoparsec.Text as A +import qualified Data.ByteString.Char8 as B import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace) import Data.Either (fromRight) import Data.Functor (($>)) @@ -34,9 +38,10 @@ import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (. import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON) import Simplex.Messaging.Protocol (ProtocolServer (..)) -import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8) +import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8, tshow) import System.Console.ANSI.Types import qualified Text.Email.Validate as Email +import qualified URI.ByteString as U data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown deriving (Eq, Show) @@ -49,7 +54,9 @@ data Format | Secret | Colored {color :: FormatColor} | Uri - | SimplexLink {linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text} + -- showText is Nothing for the usual Uri without text + | HyperLink {showText :: Maybe Text, linkUri :: Text} + | SimplexLink {showText :: Maybe Text, linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text} | Command {commandStr :: Text} | Mention {memberName :: Text} | Email @@ -187,6 +194,7 @@ markdownP = mconcat <$> A.many' fragmentP '!' -> coloredP <|> wordP '@' -> mentionP <|> wordP '/' -> commandP <|> wordP + '[' -> sowLinkP <|> wordP _ | isDigit c -> phoneP <|> wordP | otherwise -> wordP @@ -224,6 +232,20 @@ markdownP = mconcat <$> A.many' fragmentP let origStr = if c == '\'' then '\'' `T.cons` str `T.snoc` '\'' else str res = markdown (format str) (pfx `T.cons` origStr) pure $ if T.null punct then res else res :|: unmarked punct + sowLinkP = do + t <- '[' `inParens` ']' + l <- '(' `inParens` ')' + let hasPunct = T.any (\c -> isPunctuation c && c /= '-' && c /= '_') t + when (hasPunct && t /= l && ("https://" <> t) /= l) $ fail "punctuation in hyperlink text" + f <- case strDecode $ encodeUtf8 l of + Right lnk@(ACL _ cLink) -> case cLink of + CLShort _ -> pure $ simplexUriFormat (Just t) lnk + CLFull _ -> fail "full SimpleX link in hyperlink" + Left _ -> case parseUri $ encodeUtf8 l of + Right _ -> pure $ HyperLink (Just t) l + Left e -> fail $ "not uri: " <> T.unpack e + pure $ markdown f $ T.concat ["[", t, "](", l, ")"] + inParens open close = A.char open *> A.takeWhile1 (/= close) <* A.char close colorP = A.anyChar >>= \case 'r' -> optional "ed" $> Red @@ -253,7 +275,11 @@ markdownP = mconcat <$> A.many' fragmentP wordMD :: Text -> Markdown wordMD s | T.null s = unmarked s - | isUri s' = res $ uriMarkdown s' + | isUri s' = case strDecode $ encodeUtf8 s of + Right cLink -> res $ markdown (simplexUriFormat Nothing cLink) s' + Left _ -> case parseUri $ encodeUtf8 s' of + Right _ -> res $ markdown Uri s' + Left _ -> unmarked s | isDomain s' = res $ markdown Uri s' | isEmail s' = res $ markdown Email s' | otherwise = unmarked s @@ -265,9 +291,6 @@ markdownP = mconcat <$> A.many' fragmentP '/' -> False ')' -> False c -> isPunctuation c - uriMarkdown s = case strDecode $ encodeUtf8 s of - Right cLink -> markdown (simplexUriFormat cLink) s - _ -> markdown Uri s isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] -- matches what is likely to be a domain, not all valid domain names isDomain s = case T.splitOn "." s of @@ -281,11 +304,11 @@ markdownP = mconcat <$> A.many' fragmentP && (let p c = isAscii c && isAlpha c in T.all p name && T.all p tld) isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s) noFormat = pure . unmarked - simplexUriFormat :: AConnectionLink -> Format - simplexUriFormat = \case + simplexUriFormat :: Maybe Text -> AConnectionLink -> Format + simplexUriFormat showText = \case ACL m (CLFull cReq) -> case cReq of - CRContactUri crData -> SimplexLink (linkType' crData) cLink $ uriHosts crData - CRInvitationUri crData _ -> SimplexLink XLInvitation cLink $ uriHosts crData + CRContactUri crData -> SimplexLink showText (linkType' crData) cLink $ uriHosts crData + CRInvitationUri crData _ -> SimplexLink showText XLInvitation cLink $ uriHosts crData where cLink = ACL m $ CLFull $ simplexConnReqUri cReq uriHosts ConnReqUriData {crSmpQueues} = L.map strEncodeText $ sconcat $ L.map (host . qServer) crSmpQueues @@ -293,8 +316,8 @@ markdownP = mconcat <$> A.many' fragmentP Just (CRDataGroup _) -> XLGroup Nothing -> XLContact ACL m (CLShort sLnk) -> case sLnk of - CSLContact _ ct srv _ -> SimplexLink (linkType' ct) cLink $ uriHosts srv - CSLInvitation _ srv _ _ -> SimplexLink XLInvitation cLink $ uriHosts srv + CSLContact _ ct srv _ -> SimplexLink showText (linkType' ct) cLink $ uriHosts srv + CSLInvitation _ srv _ _ -> SimplexLink showText XLInvitation cLink $ uriHosts srv where cLink = ACL m $ CLShort $ simplexShortLink sLnk uriHosts srv = L.map strEncodeText $ host srv @@ -305,6 +328,24 @@ markdownP = mconcat <$> A.many' fragmentP strEncodeText :: StrEncoding a => a -> Text strEncodeText = safeDecodeUtf8 . strEncode +parseUri :: B.ByteString -> Either Text U.URI +parseUri s = case U.parseURI U.laxURIParserOptions s of + Left e -> Left $ "Invalid URI: " <> tshow e + Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority} + | sch /= "http" && sch /= "https" -> Left $ "Unsupported URI scheme: " <> safeDecodeUtf8 sch + | otherwise -> case uriAuthority of + Nothing -> Left "No URI host" + Just U.Authority {authorityHost = U.Host h} + | '.' `B.notElem` h -> Left $ "Invalid URI host: " <> safeDecodeUtf8 h + | otherwise -> Right uri + +sanitizeUri :: U.URI -> Maybe U.URI +sanitizeUri uri@U.URI {uriQuery = U.Query originalQS} = + let sanitizedQS = filter (\(p, _) -> p == "q" || p == "search") originalQS + in if length sanitizedQS == length originalQS + then Nothing + else Just $ uri {U.uriQuery = U.Query sanitizedQS} + markdownText :: FormattedText -> Text markdownText (FormattedText f_ t) = case f_ of Nothing -> t @@ -316,6 +357,7 @@ markdownText (FormattedText f_ t) = case f_ of Secret -> around '#' Colored (FormatColor c) -> color c Uri -> t + HyperLink {} -> t SimplexLink {} -> t Mention _ -> t Command _ -> t diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 5d9af59b23..23712cf992 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -1,5 +1,6 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -25,6 +26,7 @@ import Data.Functor (($>)) import Data.List (find) import qualified Data.List.NonEmpty as L import Data.Maybe (fromMaybe) +import Data.Text (Text) import Data.Word (Word8) import Foreign.C.String import Foreign.C.Types (CInt (..)) @@ -35,7 +37,7 @@ import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEnco import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Library.Commands -import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList) +import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri) import Simplex.Chat.Mobile.File import Simplex.Chat.Mobile.Shared import Simplex.Chat.Mobile.WebRTC @@ -56,6 +58,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..) import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8) import System.IO (utf8) import System.Timeout (timeout) +import qualified URI.ByteString as U #if !defined(dbPostgres) import Data.ByteArray (ScrubbedBytes) import Database.SQLite.Simple (SQLError (..)) @@ -81,6 +84,20 @@ eitherToResult :: Maybe RemoteHostId -> Either ChatError r -> APIResult r eitherToResult rhId = either (APIError rhId) (APIResult rhId) {-# INLINE eitherToResult #-} +data ParsedUri = ParsedUri + { uriInfo :: Maybe UriInfo, + parseError :: Text + } + +data UriInfo = UriInfo + { scheme :: Text, + sanitized :: Maybe Text + } + +$(JQ.deriveJSON defaultJSON ''UriInfo) + +$(JQ.deriveJSON defaultJSON ''ParsedUri) + $(pure []) instance ToJSON r => ToJSON (APIResult r) where @@ -111,6 +128,8 @@ foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO C foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString +foreign export ccall "chat_parse_uri" cChatParseUri :: CString -> IO CJSONString + foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString @@ -200,6 +219,10 @@ cChatParseMarkdown s = newCStringFromLazyBS . chatParseMarkdown =<< B.packCStrin cChatParseServer :: CString -> IO CJSONString cChatParseServer s = newCStringFromLazyBS . chatParseServer =<< B.packCString s +-- | parse web URI - returns ParsedUri JSON +cChatParseUri :: CString -> IO CJSONString +cChatParseUri s = newCStringFromLazyBS . chatParseUri =<< B.packCString s + cChatPasswordHash :: CString -> CString -> IO CString cChatPasswordHash cPwd cSalt = do pwd <- B.packCString cPwd @@ -293,6 +316,7 @@ chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase errDbStr _ -> dbError e #endif + dbError :: Show e => e -> Either DBMigrationResult DBStore dbError e = Left . DBMErrorSQL errDbStr $ show e chatCloseStore :: ChatController -> IO String @@ -342,6 +366,14 @@ chatParseServer = J.encode . toServerAddress . strDecode enc :: StrEncoding a => a -> String enc = B.unpack . strEncode +chatParseUri :: ByteString -> JSONByteString +chatParseUri s = J.encode $ case parseUri s of + Left e -> ParsedUri Nothing e + Right uri@U.URI {uriScheme = U.Scheme sch} -> + let sanitized = safeDecodeUtf8 . U.serializeURIRef' <$> sanitizeUri uri + uriInfo = UriInfo {scheme = safeDecodeUtf8 sch, sanitized} + in ParsedUri (Just uriInfo) "" + chatPasswordHash :: ByteString -> ByteString -> ByteString chatPasswordHash pwd salt = either (const "") passwordHash salt' where diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 31dc3d668a..d6a14391b0 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -20,6 +20,7 @@ markdownTests = do secretText textColor textWithUri + textWithHyperlink textWithEmail textWithPhone textWithMentions @@ -172,11 +173,11 @@ uri :: Text -> Markdown uri = Markdown $ Just Uri simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown -simplexLink linkType uriText smpHosts t = Markdown (simplexLinkFormat linkType uriText smpHosts) t +simplexLink linkType uriText smpHosts t = Markdown (simplexLinkFormat linkType uriText smpHosts Nothing) t -simplexLinkFormat :: SimplexLinkType -> Text -> NonEmpty Text -> Maybe Format -simplexLinkFormat linkType uriText smpHosts = case strDecode $ encodeUtf8 uriText of - Right simplexUri -> Just SimplexLink {linkType, simplexUri, smpHosts} +simplexLinkFormat :: SimplexLinkType -> Text -> NonEmpty Text -> Maybe Text -> Maybe Format +simplexLinkFormat linkType uriText smpHosts showText = case strDecode $ encodeUtf8 uriText of + Right simplexUri -> Just SimplexLink {linkType, simplexUri, smpHosts, showText} Left e -> error e textWithUri :: Spec @@ -210,6 +211,7 @@ textWithUri = describe "text with Uri" do "www." <==> "www." ".com" <==> ".com" "example.academytoolong" <==> "example.academytoolong" + "simplex:/example" <==> "simplex:/example" it "SimpleX links" do let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D" ("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv) @@ -222,6 +224,27 @@ textWithUri = describe "text with Uri" do ("https://simplex.chat" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr) ("simplex:" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("simplex:" <> gr) +web :: Text -> Text -> Text -> Markdown +web t u = Markdown $ Just HyperLink {showText = Just t, linkUri = u} + +textWithHyperlink :: Spec +textWithHyperlink = describe "text with HyperLink without link text" do + let addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw" + addr' = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im" + it "correct markdown" do + "[click here](https://example.com)" <==> web "click here" "https://example.com" "[click here](https://example.com)" + "For details [click here](https://example.com)" <==> "For details " <> web "click here" "https://example.com" "[click here](https://example.com)" + "[example.com](https://example.com)" <==> web "example.com" "https://example.com" "[example.com](https://example.com)" + "[example.com/page](https://example.com/page)" <==> web "example.com/page" "https://example.com/page" "[example.com/page](https://example.com/page)" + ("[Connect to me](" <> addr <> ")") <==> Markdown (simplexLinkFormat XLContact addr' ["smp6.simplex.im"] (Just "Connect to me")) ("[Connect to me](" <> addr <> ")") + it "potentially spoofed link" do + "[https://example.com](https://another.com)" <==> "[https://example.com](https://another.com)" + "[example.com/page](https://another.com/page)" <==> "[example.com/page](https://another.com/page)" + ("[Connect.to.me](" <> addr <> ")") <==> Markdown Nothing ("[Connect.to.me](" <> addr <> ")") + it "ignored as markdown" do + "[click here](example.com)" <==> "[click here](example.com)" + "[click here](https://example.com )" <==> "[click here](https://example.com )" + email :: Text -> Markdown email = Markdown $ Just Email @@ -330,7 +353,7 @@ multilineMarkdownList = describe "multiline markdown" do it "multiline with simplex link" do ("https://simplex.chat" <> inv <> "\ntext") <<==>> - [ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv), + [ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] Nothing) ("https://simplex.chat" <> inv), "\ntext" ] it "command markdown" do diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 11a89bc62e..0ba784a1fc 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -75,6 +75,9 @@ mobileTests = do it "should convert invalid name to a valid name" testValidNameCApi describe "JSON length" $ do it "should compute length of JSON encoded string" testChatJsonLengthCApi + describe "Parsers" $ do + it "should parse server address" testChatParseServer + it "should parse and sanitize URI" testChatParseUri noActiveUser :: LB.ByteString noActiveUser = @@ -318,6 +321,14 @@ testChatJsonLengthCApi _ = do cInt2 <- cChatJsonLength =<< newCString "こんにちは!" cInt2 `shouldBe` 18 +testChatParseServer :: TestParams -> IO () +testChatParseServer _ = do + pure () + +testChatParseUri :: TestParams -> IO () +testChatParseUri _ = do + pure () + jDecode :: FromJSON a => String -> IO (Maybe a) jDecode = pure . J.decode . LB.pack