diff --git a/submodules/PresentationDataUtils/BUILD b/submodules/PresentationDataUtils/BUILD index 28dcd03d33..b1861ea36d 100644 --- a/submodules/PresentationDataUtils/BUILD +++ b/submodules/PresentationDataUtils/BUILD @@ -20,6 +20,7 @@ swift_library( "//submodules/OverlayStatusController:OverlayStatusController", "//submodules/UrlWhitelist:UrlWhitelist", "//submodules/TelegramUI/Components/AlertComponent", + "//submodules/UrlHandling", ], visibility = [ "//visibility:public", diff --git a/submodules/PresentationDataUtils/Sources/OpenUrl.swift b/submodules/PresentationDataUtils/Sources/OpenUrl.swift index 1bb1262659..aa2dc5d708 100644 --- a/submodules/PresentationDataUtils/Sources/OpenUrl.swift +++ b/submodules/PresentationDataUtils/Sources/OpenUrl.swift @@ -7,6 +7,7 @@ import OverlayStatusController import UrlWhitelist import TelegramPresentationData import AlertComponent +import UrlHandling public func openUserGeneratedUrl(context: AccountContext, peerId: PeerId?, url: String, concealed: Bool, skipUrlAuth: Bool = false, skipConcealedAlert: Bool = false, forceDark: Bool = false, present: @escaping (ViewController) -> Void, openResolved: @escaping (ResolvedUrl) -> Void, progress: Promise? = nil, alertDisplayUpdated: ((ViewController?) -> Void)? = nil) -> Disposable { var concealed = concealed @@ -83,14 +84,23 @@ public func openUserGeneratedUrl(context: AccountContext, peerId: PeerId?, url: let (parsedString, parsedConcealed) = parseUrl(url: url, wasConcealed: concealed) concealed = parsedConcealed + if let parsedUrl = parseInternalUrl(sharedContext: context.sharedContext, context: context, query: url) { + if case .proxy = parsedUrl { + concealed = true + } + } + if concealed && !skipConcealedAlert { var rawDisplayUrl: String = parsedString let maxLength = 180 if rawDisplayUrl.count > maxLength { rawDisplayUrl = String(rawDisplayUrl[.. String { + guard !input.isEmpty else { throw PunycodeError.emptyInput } + + let scalars: [UInt32] = input.unicodeScalars.map { $0.value } + var output = "" + + var b = 0 + for cp in scalars where cp < 0x80 { + output.unicodeScalars.append(UnicodeScalar(cp)!) + b += 1 + } + + var h = b + if b > 0 && h < scalars.count { output.append(delimiter) } + + var n = initialN + var delta: UInt32 = 0 + var bias = initialBias + + func checkedAdd(_ a: UInt32, _ b: UInt32) throws -> UInt32 { + let (res, overflow) = a.addingReportingOverflow(b) + if overflow { throw PunycodeError.overflow } + return res + } + func checkedMul(_ a: UInt32, _ b: UInt32) throws -> UInt32 { + let (res, overflow) = a.multipliedReportingOverflow(by: b) + if overflow { throw PunycodeError.overflow } + return res + } + + while h < scalars.count { + var m: UInt32 = UInt32.max + for cp in scalars where cp >= n { + if cp < m { m = cp } + } + + let hm1 = UInt32(h + 1) + delta = try checkedAdd(delta, try checkedMul(m - n, hm1)) + n = m + + for cp in scalars { + if cp < n { + delta = try checkedAdd(delta, 1) + } else if cp == n { + var q = delta + var k = base + + while true { + let t: UInt32 + if k <= bias { t = UInt32(tmin) } + else if k >= bias + tmax { t = UInt32(tmax) } + else { t = UInt32(k - bias) } + + if q < t { break } + + let digit = Int(t + (q - t) % UInt32(base - Int(t))) + output.append(encodeDigit(digit)) + q = (q - t) / UInt32(base - Int(t)) + k += base + } + + output.append(encodeDigit(Int(q))) + bias = adapt(delta: delta, numPoints: h + 1, firstTime: h == b) + delta = 0 + h += 1 + } + } + + delta = try checkedAdd(delta, 1) + n = try checkedAdd(n, 1) + } + + return output + } + + /// Encodes a domain name label-by-label; prefixes "xn--" only when needed. + static func encodeDomain(_ domain: String) throws -> String { + let normalized = domain.precomposedStringWithCanonicalMapping + + return try normalized + .split(separator: ".", omittingEmptySubsequences: false) + .map { piece -> String in + let label = String(piece) + if label.isEmpty { return label } // keep empty labels as-is (rare) + if label.unicodeScalars.allSatisfy({ $0.isASCII }) { return label } + return "xn--" + (try encodeLabel(label)) + } + .joined(separator: ".") + } + + private static func encodeDigit(_ d: Int) -> Character { + precondition((0..<36).contains(d)) + if d < 26 { return Character(UnicodeScalar(UInt8(97 + d))) } // a-z + else { return Character(UnicodeScalar(UInt8(48 + (d - 26)))) } // 0-9 + } + + private static func adapt(delta: UInt32, numPoints: Int, firstTime: Bool) -> Int { + var d = Int(delta) + d = firstTime ? d / damp : d / 2 + d += d / numPoints + + var k = 0 + while d > ((base - tmin) * tmax) / 2 { + d /= (base - tmin) + k += base + } + + return k + (base - tmin + 1) * d / (d + skew) + } +} + +private enum URLPunycodeError: Error { + case invalidURL +} + +/// Converts a full URL string to an ASCII-safe form: +/// - punycodes the host (IDN) using RFC 3492 punycode +/// - leaves scheme/port/user/pass intact +/// - lets URLComponents percent-encode path/query/fragment as needed +private func punycodedFullURLString(_ input: String) throws -> String { + guard var comps = URLComponents(string: input) else { + throw URLPunycodeError.invalidURL + } + + // If there is a host, encode it. If it's a relative URL, host may be nil. + if let host = comps.host, !host.isEmpty { + comps.host = try Punycode.encodeDomain(host) + } + + // URLComponents.string can be nil for some malformed component combinations + if let out = comps.string { return out } + throw URLPunycodeError.invalidURL +} diff --git a/submodules/UrlEscaping/Sources/UrlEscaping.swift b/submodules/UrlEscaping/Sources/UrlEscaping.swift index bbf09a6488..c2aa86f4e7 100644 --- a/submodules/UrlEscaping/Sources/UrlEscaping.swift +++ b/submodules/UrlEscaping/Sources/UrlEscaping.swift @@ -4,6 +4,10 @@ public func doesUrlMatchText(url: String, text: String, fullText: String) -> Boo if fullText.range(of: "\u{202e}") != nil { return false } + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~:/?#[]@!$&'()*+,;=%") + if !url.unicodeScalars.allSatisfy({ allowed.contains($0) }) { + return false + } if url == text { return true }