95 lines
3.2 KiB
Swift
95 lines
3.2 KiB
Swift
//
|
|
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
|
|
public enum LinkValidator {
|
|
public static func canParseURLs(in entireMessage: String) -> Bool {
|
|
if entireMessage.unicodeScalars.contains(where: isProblematicCodepointAnywhereInString(_:)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
public static func isValidLink(linkText: String) -> Bool {
|
|
if linkText.unicodeScalars.contains(where: isProblematicCodepointInLink(_:)) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private static func isProblematicCodepointAnywhereInString(_ scalar: UnicodeScalar) -> Bool {
|
|
switch scalar {
|
|
case "\u{202C}", // POP DIRECTIONAL FORMATTING
|
|
"\u{202D}", // LEFT-TO-RIGHT OVERRIDE
|
|
"\u{202E}": // RIGHT-TO-LEFT OVERRIDE
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private static func isProblematicCodepointInLink(_ scalar: UnicodeScalar) -> Bool {
|
|
if isProblematicCodepointAnywhereInString(scalar) {
|
|
return true
|
|
}
|
|
switch scalar {
|
|
case "\u{2500}"..."\u{25FF}": // Box Drawing, Block Elements, Geometric Shapes
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
public static func firstLinkPreviewURL(in entireMessage: MessageBody) -> URL? {
|
|
// Don't include link previews for oversize text messages.
|
|
guard entireMessage.text.utf8.dropFirst(Int(kOversizeTextMessageSizeThreshold)).isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
guard canParseURLs(in: entireMessage.text) else {
|
|
return nil
|
|
}
|
|
|
|
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
|
owsFailDebug("Could not create NSDataDetector")
|
|
return nil
|
|
}
|
|
|
|
var result: URL?
|
|
detector.enumerateMatches(
|
|
in: entireMessage.text,
|
|
range: entireMessage.text.entireRange
|
|
) { match, _, stop in
|
|
guard let match = match else { return }
|
|
guard let parsedUrl = match.url else { return }
|
|
guard let matchedRange = Range(match.range, in: entireMessage.text) else { return }
|
|
for style in entireMessage.ranges.collapsedStyles {
|
|
if
|
|
style.value.style.contains(style: .spoiler),
|
|
style.range.intersection(match.range)?.location ?? NSNotFound != NSNotFound
|
|
{
|
|
return
|
|
}
|
|
// Styles are ordered; no need to search past the matched range.
|
|
if style.range.lowerBound >= match.range.upperBound {
|
|
break
|
|
}
|
|
}
|
|
guard parsedUrl.absoluteString.isEmpty.negated else { return }
|
|
guard
|
|
LinkPreviewHelper.isPermittedLinkPreviewUrl(
|
|
parsedUrl,
|
|
parsedFrom: String(entireMessage.text[matchedRange])
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
result = parsedUrl
|
|
stop.pointee = true
|
|
}
|
|
return result
|
|
}
|
|
}
|