| @@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St |
| 387 | 387 | let alt = nsText.substring(with: match.range(at: 1)) |
| 388 | 388 | let source = nsText.substring(with: match.range(at: 2)) |
| 389 | 389 | let resolvedSource = imageURLResolver?(source) ?? source |
| 390 | | return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# |
| 390 | guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { |
| 391 | return escapeHTML(alt) |
| 392 | } |
| 393 | return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"# |
| 391 | 394 | } |
| 392 | 395 | // Links: [text](url) |
| 393 | | result = result.replacingOccurrences( |
| 394 | | of: #"\[([^\]]+)\]\(([^)]+)\)"#, |
| 395 | | with: #"<a href="$2">$1</a>"#, |
| 396 | | options: .regularExpression |
| 397 | | ) |
| 396 | result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in |
| 397 | let label = nsText.substring(with: match.range(at: 1)) |
| 398 | let rawURL = nsText.substring(with: match.range(at: 2)) |
| 399 | guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { |
| 400 | return label |
| 401 | } |
| 402 | return #"<a href="\#(sanitizedURL)">\#(label)</a>"# |
| 403 | } |
| 398 | 404 | // Bold: **text** |
| 399 | 405 | result = result.replacingOccurrences( |
| 400 | 406 | of: #"\*\*(.+?)\*\*"#, |
| @@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St |
| 676 | 682 | ) { |
| 677 | 683 | return imageHTML |
| 678 | 684 | } |
| 679 | | return #"<a href="\#(url)">\#(label)</a>"# |
| 685 | guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { |
| 686 | return label |
| 687 | } |
| 688 | return #"<a href="\#(sanitizedURL)">\#(label)</a>"# |
| 680 | 689 | } |
| 681 | 690 | result = protectMatches( |
| 682 | 691 | in: result, |
| @@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St |
| 691 | 700 | ) { |
| 692 | 701 | return imageHTML |
| 693 | 702 | } |
| 694 | | return #"<a href="\#(url)">\#(url)</a>"# |
| 703 | guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { |
| 704 | return url |
| 705 | } |
| 706 | return #"<a href="\#(sanitizedURL)">\#(url)</a>"# |
| 695 | 707 | } |
| 696 | 708 | result = protectMatches( |
| 697 | 709 | in: result, |
| @@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String { |
| 742 | 754 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") |
| 743 | 755 | } |
| 744 | 756 | |
| 757 | nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { |
| 758 | sanitizeReadmeURLString( |
| 759 | rawURL, |
| 760 | allowedSchemes: ["http", "https", "mailto"], |
| 761 | allowsFragmentOnly: true |
| 762 | ) |
| 763 | } |
| 764 | |
| 765 | nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { |
| 766 | sanitizeReadmeURLString( |
| 767 | rawURL, |
| 768 | allowedSchemes: ["http", "https"], |
| 769 | allowsFragmentOnly: false |
| 770 | ) |
| 771 | } |
| 772 | |
| 773 | nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { |
| 774 | guard let scheme = url.scheme?.lowercased() else { |
| 775 | return false |
| 776 | } |
| 777 | if scheme == "about" || scheme == "data" { |
| 778 | return true |
| 779 | } |
| 780 | guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { |
| 781 | return false |
| 782 | } |
| 783 | return sanitizedURL == escapeHTMLAttribute(url.absoluteString) |
| 784 | } |
| 785 | |
| 786 | nonisolated private func sanitizeReadmeURLString( |
| 787 | _ rawURL: String, |
| 788 | allowedSchemes: Set<String>, |
| 789 | allowsFragmentOnly: Bool |
| 790 | ) -> String? { |
| 791 | let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| 792 | guard !trimmedURL.isEmpty else { return nil } |
| 793 | |
| 794 | if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { |
| 795 | return escapeHTMLAttribute(trimmedURL) |
| 796 | } |
| 797 | |
| 798 | guard let components = URLComponents(string: trimmedURL), |
| 799 | let scheme = components.scheme?.lowercased(), |
| 800 | allowedSchemes.contains(scheme), |
| 801 | let sanitizedURL = components.url?.absoluteString else { |
| 802 | return nil |
| 803 | } |
| 804 | |
| 805 | return escapeHTMLAttribute(sanitizedURL) |
| 806 | } |
| 807 | |
| 745 | 808 | nonisolated private func isOrgTableLine(_ line: String) -> Bool { |
| 746 | 809 | line.hasPrefix("|") && line.hasSuffix("|") |
| 747 | 810 | } |
| @@ -899,6 +962,7 @@ struct HTMLWebView: View { |
| 899 | 962 | let html: String |
| 900 | 963 | let colorScheme: ColorScheme |
| 901 | 964 | var style: HTMLWebViewStyle = .readme |
| 965 | @Environment(\.openURL) private var openURL |
| 902 | 966 | @State private var contentHeight: CGFloat = 1 |
| 903 | 967 | @State private var loadError: String? |
| 904 | 968 | @State private var reloadToken = 0 |
| @@ -921,6 +985,7 @@ struct HTMLWebView: View { |
| 921 | 985 | html: html, |
| 922 | 986 | colorScheme: colorScheme, |
| 923 | 987 | style: style, |
| 988 | openURL: openURL, |
| 924 | 989 | dynamicHeight: $contentHeight, |
| 925 | 990 | loadError: $loadError, |
| 926 | 991 | reloadToken: reloadToken |
| @@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { |
| 956 | 1021 | let html: String |
| 957 | 1022 | let colorScheme: ColorScheme |
| 958 | 1023 | let style: HTMLWebViewStyle |
| 1024 | let openURL: OpenURLAction |
| 959 | 1025 | @Binding var dynamicHeight: CGFloat |
| 960 | 1026 | @Binding var loadError: String? |
| 961 | 1027 | let reloadToken: Int |
| @@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { |
| 966 | 1032 | |
| 967 | 1033 | func makeUIView(context: Context) -> WKWebView { |
| 968 | 1034 | let config = WKWebViewConfiguration() |
| 969 | | config.defaultWebpagePreferences.allowsContentJavaScript = true |
| 1035 | config.defaultWebpagePreferences.allowsContentJavaScript = false |
| 970 | 1036 | config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore |
| 971 | 1037 | let webView = WKWebView(frame: .zero, configuration: config) |
| 972 | 1038 | webView.isOpaque = false |
| 973 | 1039 | webView.backgroundColor = .clear |
| 974 | 1040 | webView.clipsToBounds = false |
| 1041 | webView.allowsLinkPreview = false |
| 975 | 1042 | webView.scrollView.isScrollEnabled = false |
| 976 | 1043 | webView.scrollView.contentInsetAdjustmentBehavior = .never |
| 977 | 1044 | webView.scrollView.clipsToBounds = false |
| @@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc |
| 1088 | 1155 | handleLoadFailure(error) |
| 1089 | 1156 | } |
| 1090 | 1157 | |
| 1158 | func webView( |
| 1159 | _ webView: WKWebView, |
| 1160 | decidePolicyFor navigationAction: WKNavigationAction, |
| 1161 | decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void |
| 1162 | ) { |
| 1163 | guard let requestURL = navigationAction.request.url else { |
| 1164 | decisionHandler(.allow) |
| 1165 | return |
| 1166 | } |
| 1167 | |
| 1168 | if navigationAction.navigationType == .linkActivated { |
| 1169 | if isAllowedReadmeNavigationURL(requestURL) { |
| 1170 | parent.openURL(requestURL) |
| 1171 | } |
| 1172 | decisionHandler(.cancel) |
| 1173 | return |
| 1174 | } |
| 1175 | |
| 1176 | if isAllowedReadmeNavigationURL(requestURL) { |
| 1177 | decisionHandler(.allow) |
| 1178 | } else { |
| 1179 | decisionHandler(.cancel) |
| 1180 | } |
| 1181 | } |
| 1182 | |
| 1091 | 1183 | private func handleLoadFailure(_ error: Error) { |
| 1092 | 1184 | let nsError = error as NSError |
| 1093 | 1185 | guard nsError.code != NSURLErrorCancelled else { return } |
| @@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc |
| 1097 | 1189 | } |
| 1098 | 1190 | |
| 1099 | 1191 | private func updateHeight(for webView: WKWebView) { |
| 1100 | | let script = """ |
| 1101 | | Math.max( |
| 1102 | | document.body.scrollHeight, |
| 1103 | | document.body.offsetHeight, |
| 1104 | | document.documentElement.scrollHeight, |
| 1105 | | document.documentElement.offsetHeight, |
| 1106 | | Math.ceil(document.body.getBoundingClientRect().height), |
| 1107 | | Math.ceil(document.documentElement.getBoundingClientRect().height) |
| 1108 | | ) |
| 1109 | | """ |
| 1110 | | |
| 1111 | | webView.evaluateJavaScript(script) { [weak self] result, _ in |
| 1112 | | guard let value = result as? Double, value > 0 else { return } |
| 1113 | | let height = ceil(value) + 4 |
| 1114 | | DispatchQueue.main.async { |
| 1115 | | guard let self else { return } |
| 1116 | | if let html = self.lastHTML { |
| 1117 | | Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) |
| 1118 | | } |
| 1119 | | if abs(self.parent.dynamicHeight - height) > 0.5 { |
| 1120 | | self.parent.dynamicHeight = height |
| 1121 | | } |
| 1192 | webView.layoutIfNeeded() |
| 1193 | let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 |
| 1194 | guard height > 0 else { return } |
| 1195 | DispatchQueue.main.async { |
| 1196 | if let html = self.lastHTML { |
| 1197 | Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) |
| 1198 | } |
| 1199 | if abs(self.parent.dynamicHeight - height) > 0.5 { |
| 1200 | self.parent.dynamicHeight = height |
| 1122 | 1201 | } |
| 1123 | 1202 | } |
| 1124 | 1203 | } |