diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 15ee958e27..7240e46301 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -16359,3 +16359,12 @@ Error: %8$@"; "ChatList.ClearSearchHistory.Confirm" = "Clear"; "ChatList.RemoveFolderConfirmationTitle" = "Remove %@?"; + +"SocksProxySetup.QrCode.TgLink" = "tg:// link"; +"SocksProxySetup.QrCode.TMeLink" = "t.me link"; + +"WebBrowser.OpenLinksInfo" = "Open links inside Telegram instead of your default browser for more privacy."; +"WebBrowser.Exceptions.OpenInApp" = "OPEN IN-APP"; +"WebBrowser.Exceptions.DontOpenInApp" = "DON'T OPEN IN-APP"; +"WebBrowser.Exceptions.InAppInfo" = "These sites will still be opened in-app."; +"WebBrowser.Exceptions.DeleteAll" = "Delete All Exceptions"; diff --git a/submodules/QrCodeUI/Sources/QrCodeScreen.swift b/submodules/QrCodeUI/Sources/QrCodeScreen.swift index 0cbbbe41ea..fd01c51c1a 100644 --- a/submodules/QrCodeUI/Sources/QrCodeScreen.swift +++ b/submodules/QrCodeUI/Sources/QrCodeScreen.swift @@ -224,13 +224,12 @@ private final class SheetContent: CombinedComponent { dividerColor: theme.rootController.navigationBar.segmentedDividerColor ) - //TODO:localize let segmentControl = segmentControl.update( component: SegmentControlComponent( theme: theme, items: [ - SegmentControlComponent.Item(id: AnyHashable(false), title: "tg:// link"), - SegmentControlComponent.Item(id: AnyHashable(true), title: "t.me link") + SegmentControlComponent.Item(id: AnyHashable(false), title: strings.SocksProxySetup_QrCode_TgLink), + SegmentControlComponent.Item(id: AnyHashable(true), title: strings.SocksProxySetup_QrCode_TMeLink) ], selectedId: AnyHashable(state.selectedProxyExternalLink), action: { id in diff --git a/submodules/SettingsUI/Sources/Data and Storage/WebBrowserSettingsController.swift b/submodules/SettingsUI/Sources/Data and Storage/WebBrowserSettingsController.swift index 7eafa1200a..eacdffcb4f 100644 --- a/submodules/SettingsUI/Sources/Data and Storage/WebBrowserSettingsController.swift +++ b/submodules/SettingsUI/Sources/Data and Storage/WebBrowserSettingsController.swift @@ -337,14 +337,14 @@ private func webBrowserSettingsControllerEntries(context: AccountContext, presen index += 1 } - entries.append(.browserInfo(presentationData.theme, "Open links inside Telegram instead of your default browser for more privacy.")) + entries.append(.browserInfo(presentationData.theme, presentationData.strings.WebBrowser_OpenLinksInfo)) entries.append(.clearCookies(presentationData.theme, presentationData.strings.WebBrowser_ClearCookies)) entries.append(.clearCookiesInfo(presentationData.theme, presentationData.strings.WebBrowser_ClearCookies_Info)) //TODO:localize if accountSettings.openExternalBrowser { - entries.append(.neverHeader(presentationData.theme, "OPEN IN-APP")) + entries.append(.neverHeader(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_OpenInApp)) entries.append(.neverAdd(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_AddException)) var exceptionIndex: Int32 = 0 @@ -352,13 +352,13 @@ private func webBrowserSettingsControllerEntries(context: AccountContext, presen entries.append(.neverException(exceptionIndex, presentationData.theme, exception)) exceptionIndex += 1 } - entries.append(.neverExceptionsInfo(presentationData.theme, "These sites will still be opened in-app.")) + entries.append(.neverExceptionsInfo(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_InAppInfo)) if !accountSettings.inAppExceptions.isEmpty { - entries.append(.neverExceptionsClear(presentationData.theme, "Delete All Exceptions")) + entries.append(.neverExceptionsClear(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_DeleteAll)) } } else { - entries.append(.alwaysHeader(presentationData.theme, "DON'T OPEN IN-APP")) + entries.append(.alwaysHeader(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_DontOpenInApp)) entries.append(.alwaysAdd(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_AddException)) var exceptionIndex: Int32 = 0 @@ -369,7 +369,7 @@ private func webBrowserSettingsControllerEntries(context: AccountContext, presen entries.append(.alwaysExceptionsInfo(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_Info)) if !accountSettings.externalExceptions.isEmpty { - entries.append(.alwaysExceptionsClear(presentationData.theme, "Delete All Exceptions")) + entries.append(.alwaysExceptionsClear(presentationData.theme, presentationData.strings.WebBrowser_Exceptions_DeleteAll)) } } diff --git a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift index 7753274151..6f5e25f639 100644 --- a/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift +++ b/submodules/TelegramUI/Components/ComposePollScreen/Sources/ComposePollScreen.swift @@ -557,9 +557,7 @@ final class ComposePollScreenComponent: Component { mappedKind = .poll(multipleAnswers: self.effectiveIsMultiAnswer && mappedOptions.count > 1) } - if self.isQuiz && mappedOptions.count < 2 { - return .optionsNeeded - } else if !self.isQuiz && mappedOptions.count < 1 { + if mappedOptions.count < 1 { return .optionsNeeded } diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift index e328408aa0..1ccdf29d21 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoProfileItems.swift @@ -943,7 +943,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s } else { programTitleValue = .text(presentationData.strings.PeerInfo_ItemAffiliateProgram_ValueOff) } - items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAffiliateProgram, label: programTitleValue, additionalBadgeLabel: presentationData.strings.Settings_New, text: presentationData.strings.PeerInfo_ItemAffiliateProgram_Title, icon: PresentationResourcesSettings.affiliateProgram, action: { + items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAffiliateProgram, label: programTitleValue, text: presentationData.strings.PeerInfo_ItemAffiliateProgram_Title, icon: PresentationResourcesSettings.affiliateProgram, action: { interaction.editingOpenAffiliateProgram() })) } @@ -1153,24 +1153,6 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s var boostIcon: UIImage? if let approximateBoostLevel = channel.approximateBoostLevel, approximateBoostLevel < 1 { boostIcon = generateDisclosureActionBoostLevelBadgeImage(text: presentationData.strings.Channel_Info_BoostLevelPlusBadge("1").string) - } else { - /*let labelText = NSAttributedString(string: presentationData.strings.Settings_New, font: Font.medium(11.0), textColor: presentationData.theme.list.itemCheckColors.foregroundColor) - let labelBounds = labelText.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil) - let labelSize = CGSize(width: ceil(labelBounds.width), height: ceil(labelBounds.height)) - let badgeSize = CGSize(width: labelSize.width + 8.0, height: labelSize.height + 2.0 + 1.0) - boostIcon = generateImage(badgeSize, rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - - let rect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height - UIScreenPixel * 2.0)) - - context.addPath(UIBezierPath(roundedRect: rect, cornerRadius: 5.0).cgPath) - context.setFillColor(presentationData.theme.list.itemCheckColors.fillColor.cgColor) - context.fillPath() - - UIGraphicsPushContext(context) - labelText.draw(at: CGPoint(x: 4.0, y: 1.0 + UIScreenPixel)) - UIGraphicsPopContext() - })*/ } items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPeerColor, label: .image(colorImage, colorImage.size), additionalBadgeIcon: boostIcon, text: presentationData.strings.Channel_Info_AppearanceItem, icon: PresentationResourcesSettings.chatAppearance, action: { interaction.editingOpenNameColorSetup() @@ -1226,7 +1208,7 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s labelString = NSAttributedString(string: presentationData.strings.PeerInfo_AllowChannelMessages_Off, font: labelFont, textColor: labelColor) } - items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPostSuggestionsSettings, label: .attributedText(labelString), additionalBadgeLabel: presentationData.strings.Settings_New, text: presentationData.strings.PeerInfo_AllowChannelMessages, icon: PresentationResourcesSettings.channelMessages, action: { + items[.peerSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemPostSuggestionsSettings, label: .attributedText(labelString), text: presentationData.strings.PeerInfo_AllowChannelMessages, icon: PresentationResourcesSettings.channelMessages, action: { interaction.editingOpenPostSuggestionsSetup() })) @@ -1461,23 +1443,6 @@ func editingItems(data: PeerInfoScreenData?, boostStatus: ChannelBoostStatus?, s boostIcon = generateDisclosureActionBoostLevelBadgeImage(text: presentationData.strings.Channel_Info_BoostLevelPlusBadge("1").string) } else { boostIcon = nil - /*let labelText = NSAttributedString(string: presentationData.strings.Settings_New, font: Font.medium(11.0), textColor: presentationData.theme.list.itemCheckColors.foregroundColor) - let labelBounds = labelText.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: [.usesLineFragmentOrigin], context: nil) - let labelSize = CGSize(width: ceil(labelBounds.width), height: ceil(labelBounds.height)) - let badgeSize = CGSize(width: labelSize.width + 8.0, height: labelSize.height + 2.0 + 1.0) - boostIcon = generateImage(badgeSize, rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - - let rect = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height - UIScreenPixel * 2.0)) - - context.addPath(UIBezierPath(roundedRect: rect, cornerRadius: 5.0).cgPath) - context.setFillColor(presentationData.theme.list.itemCheckColors.fillColor.cgColor) - context.fillPath() - - UIGraphicsPushContext(context) - labelText.draw(at: CGPoint(x: 4.0, y: 1.0 + UIScreenPixel)) - UIGraphicsPopContext() - })*/ } items[.peerDataSettings]!.append(PeerInfoScreenDisclosureItem(id: ItemAppearance, label: .image(colorImage, colorImage.size), additionalBadgeIcon: boostIcon, text: presentationData.strings.Channel_Info_AppearanceItem, icon: PresentationResourcesSettings.chatAppearance, action: { interaction.editingOpenNameColorSetup() diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index e441db4930..ee39fd2940 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1262,7 +1262,9 @@ public final class WebAppController: ViewController, AttachmentContainable { case "web_app_open_tg_link": if let json = json, let path = json["path_full"] as? String { let forceRequest = json["force_request"] as? Bool ?? false - controller.openUrl("https://t.me\(path)", false, forceRequest, {}) + if let url = makeWebAppTelegramLink(pathFull: path) { + controller.openUrl(url, false, forceRequest, {}) + } } case "web_app_open_invoice": if let json = json, let slug = json["slug"] as? String { @@ -4349,116 +4351,3 @@ private struct WebAppConfiguration { } } } - -private func isAllowedBotMediaUrl(_ urlString: String) -> Bool { - guard let escaped = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), - let url = URL(string: escaped) else { - return false - } - guard url.scheme?.lowercased() == "https" else { - return false - } - if url.user != nil || url.password != nil { - return false - } - guard var host = url.host?.lowercased(), !host.isEmpty else { - return false - } - if host.hasPrefix("[") && host.hasSuffix("]") { - host = String(host.dropFirst().dropLast()) - } - - // Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255). - // Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as - // decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the - // same string as octal 127.0.0.1 — the divergence is a loopback bypass. - if let v4Bytes = parseCanonicalIPv4(host) { - return isPublicIPv4(v4Bytes) - } - - // IPv6 only — host must contain ":" so we don't accidentally hand a - // numeric-looking hostname to inet_pton. - if host.contains(":") { - var v6 = in6_addr() - if host.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { - let bytes = withUnsafeBytes(of: &v6) { ptr -> [UInt8] in - return Array(ptr) - } - return isPublicIPv6(bytes) - } - return false - } - - // Strict DNS-name validation. Anything that doesn't look like a real - // FQDN is rejected — this catches non-canonical numeric IP forms - // (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like - // "0x7f.0.0.1", short forms like "127.1") that the OS resolver may - // still treat as 127.0.0.1 even when inet_pton would accept them as - // a different value or reject outright. - let labels = host.split(separator: ".", omittingEmptySubsequences: false) - guard labels.count >= 2 else { return false } - for label in labels { - guard !label.isEmpty, label.count <= 63 else { return false } - if label.first == "-" || label.last == "-" { return false } - for ch in label { - guard ch.isASCII else { return false } - if !(ch.isLetter || ch.isNumber || ch == "-") { return false } - } - } - guard let tld = labels.last, tld.count >= 2, tld.contains(where: { $0.isLetter }) else { - return false - } - - if host == "localhost" || host.hasSuffix(".localhost") || host.hasSuffix(".local") { - return false - } - return true -} - -private func parseCanonicalIPv4(_ host: String) -> [UInt8]? { - let parts = host.split(separator: ".", omittingEmptySubsequences: false) - guard parts.count == 4 else { return nil } - var bytes: [UInt8] = [] - bytes.reserveCapacity(4) - for part in parts { - guard !part.isEmpty, part.count <= 3 else { return nil } - if part.count > 1 && part.first == "0" { return nil } // no leading zeros (octal-spoof) - guard part.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } - guard let value = UInt8(part) else { return nil } // also caps at 255 - bytes.append(value) - } - return bytes -} - -private func isPublicIPv4(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 4 else { return false } - let a = bytes[0] - let b = bytes[1] - if a == 0 { return false } // 0.0.0.0/8 - if a == 10 { return false } // 10.0.0.0/8 - if a == 127 { return false } // 127.0.0.0/8 loopback - if a == 169 && b == 254 { return false } // 169.254.0.0/16 link-local - if a == 172 && (b & 0xf0) == 16 { return false } // 172.16.0.0/12 - if a == 192 && b == 168 { return false } // 192.168.0.0/16 - if a == 100 && (b & 0xc0) == 64 { return false } // 100.64.0.0/10 CGNAT - if a >= 224 { return false } // multicast + reserved + 255.255.255.255 - return true -} - -private func isPublicIPv6(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 16 else { return false } - if bytes.allSatisfy({ $0 == 0 }) { return false } // :: - let loopback: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1] - if bytes == loopback { return false } // ::1 - if bytes[0] == 0xff { return false } // ff00::/8 multicast - if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80 { return false } // fe80::/10 link-local - if (bytes[0] & 0xfe) == 0xfc { return false } // fc00::/7 unique-local - let v4MappedPrefix: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0xff,0xff] - if Array(bytes.prefix(12)) == v4MappedPrefix { // ::ffff:a.b.c.d - return isPublicIPv4(Array(bytes.suffix(4))) - } - if Array(bytes.prefix(12)).allSatisfy({ $0 == 0 }) { // ::a.b.c.d (deprecated) - return isPublicIPv4(Array(bytes.suffix(4))) - } - return true -} diff --git a/submodules/WebUI/Sources/WebAppUtils.swift b/submodules/WebUI/Sources/WebAppUtils.swift new file mode 100644 index 0000000000..38ddd92cf6 --- /dev/null +++ b/submodules/WebUI/Sources/WebAppUtils.swift @@ -0,0 +1,144 @@ +import Foundation + +func makeWebAppTelegramLink(pathFull: String) -> String? { + guard pathFull.hasPrefix("/"), !pathFull.hasPrefix("//") else { + return nil + } + if pathFull.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + return nil + } + if pathFull.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7f }) { + return nil + } + if pathFull.contains("#") { + return nil + } + + let urlString = "https://t.me\(pathFull)" + guard let url = URL(string: urlString) else { + return nil + } + guard url.scheme?.lowercased() == "https" else { + return nil + } + guard url.host?.lowercased() == "t.me" else { + return nil + } + guard url.user == nil, url.password == nil, url.fragment == nil else { + return nil + } + return url.absoluteString +} + +func isAllowedBotMediaUrl(_ urlString: String) -> Bool { + guard let escaped = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), + let url = URL(string: escaped) else { + return false + } + guard url.scheme?.lowercased() == "https" else { + return false + } + if url.user != nil || url.password != nil { + return false + } + guard var host = url.host?.lowercased(), !host.isEmpty else { + return false + } + if host.hasPrefix("[") && host.hasSuffix("]") { + host = String(host.dropFirst().dropLast()) + } + + // Strict canonical dotted-decimal IPv4 (4 octets, no leading zeros, each 0-255). + // Do NOT use inet_pton here: Darwin's inet_pton accepts "0177.0.0.1" as + // decimal 177.0.0.1, but getaddrinfo (used by URLSession) interprets the + // same string as octal 127.0.0.1 — the divergence is a loopback bypass. + if let v4Bytes = parseCanonicalIPv4(host) { + return isPublicIPv4(v4Bytes) + } + + // IPv6 only — host must contain ":" so we don't accidentally hand a + // numeric-looking hostname to inet_pton. + if host.contains(":") { + var v6 = in6_addr() + if host.withCString({ inet_pton(AF_INET6, $0, &v6) }) == 1 { + let bytes = withUnsafeBytes(of: &v6) { ptr -> [UInt8] in + return Array(ptr) + } + return isPublicIPv6(bytes) + } + return false + } + + // Strict DNS-name validation. Anything that doesn't look like a real + // FQDN is rejected — this catches non-canonical numeric IP forms + // (decimal-32 like "2130706433", octal like "0177.0.0.1", hex like + // "0x7f.0.0.1", short forms like "127.1") that the OS resolver may + // still treat as 127.0.0.1 even when inet_pton would accept them as + // a different value or reject outright. + let labels = host.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return false } + for label in labels { + guard !label.isEmpty, label.count <= 63 else { return false } + if label.first == "-" || label.last == "-" { return false } + for ch in label { + guard ch.isASCII else { return false } + if !(ch.isLetter || ch.isNumber || ch == "-") { return false } + } + } + guard let tld = labels.last, tld.count >= 2, tld.contains(where: { $0.isLetter }) else { + return false + } + + if host == "localhost" || host.hasSuffix(".localhost") || host.hasSuffix(".local") { + return false + } + return true +} + +private func parseCanonicalIPv4(_ host: String) -> [UInt8]? { + let parts = host.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + var bytes: [UInt8] = [] + bytes.reserveCapacity(4) + for part in parts { + guard !part.isEmpty, part.count <= 3 else { return nil } + if part.count > 1 && part.first == "0" { return nil } // no leading zeros (octal-spoof) + guard part.allSatisfy({ $0.isASCII && $0.isNumber }) else { return nil } + guard let value = UInt8(part) else { return nil } // also caps at 255 + bytes.append(value) + } + return bytes +} + +private func isPublicIPv4(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 4 else { return false } + let a = bytes[0] + let b = bytes[1] + if a == 0 { return false } // 0.0.0.0/8 + if a == 10 { return false } // 10.0.0.0/8 + if a == 127 { return false } // 127.0.0.0/8 loopback + if a == 169 && b == 254 { return false } // 169.254.0.0/16 link-local + if a == 172 && (b & 0xf0) == 16 { return false } // 172.16.0.0/12 + if a == 192 && b == 168 { return false } // 192.168.0.0/16 + if a == 100 && (b & 0xc0) == 64 { return false } // 100.64.0.0/10 CGNAT + if a >= 224 { return false } // multicast + reserved + 255.255.255.255 + return true +} + +private func isPublicIPv6(_ bytes: [UInt8]) -> Bool { + guard bytes.count == 16 else { return false } + if bytes.allSatisfy({ $0 == 0 }) { return false } // :: + let loopback: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1] + if bytes == loopback { return false } // ::1 + if bytes[0] == 0xff { return false } // ff00::/8 multicast + if bytes[0] == 0xfe && (bytes[1] & 0xc0) == 0x80 { return false } // fe80::/10 link-local + if (bytes[0] & 0xfe) == 0xfc { return false } // fc00::/7 unique-local + let v4MappedPrefix: [UInt8] = [0,0,0,0,0,0,0,0,0,0,0xff,0xff] + if Array(bytes.prefix(12)) == v4MappedPrefix { // ::ffff:a.b.c.d + return isPublicIPv4(Array(bytes.suffix(4))) + } + if Array(bytes.prefix(12)).allSatisfy({ $0 == 0 }) { // ::a.b.c.d (deprecated) + return isPublicIPv4(Array(bytes.suffix(4))) + } + return true +}