Various fixes

This commit is contained in:
Ilya Laktyushin 2026-06-04 19:49:04 +02:00
parent 0b912f26de
commit 45cc8468bb
7 changed files with 167 additions and 163 deletions

View file

@ -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";

View file

@ -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

View file

@ -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))
}
}

View file

@ -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
}

View file

@ -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()

View file

@ -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
}

View file

@ -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
}