@capgo/capacitor-inappbrowser 8.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CapgoCapacitorInappbrowser.podspec +17 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +28 -0
  4. package/README.md +1610 -0
  5. package/android/build.gradle +64 -0
  6. package/android/src/main/AndroidManifest.xml +22 -0
  7. package/android/src/main/assets/proxy-bridge.js +586 -0
  8. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ActiveWebViewSupport.java +10 -0
  9. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/CustomSchemeInterceptSupport.java +51 -0
  10. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/InAppBrowserPlugin.java +1820 -0
  11. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/MobileAppBridgeScript.java +122 -0
  12. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/NativeProxyRule.java +128 -0
  13. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/Options.java +725 -0
  14. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyBridge.java +93 -0
  15. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyRequestSupport.java +869 -0
  16. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/ProxyResponseRouting.java +35 -0
  17. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/SafeAreaInsetsSupport.java +28 -0
  18. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewCallbacks.java +42 -0
  19. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewDialog.java +6022 -0
  20. package/android/src/main/java/ee/forgr/capacitor_inappbrowser/WebViewPermissionSupport.java +58 -0
  21. package/android/src/main/res/.gitkeep +0 -0
  22. package/android/src/main/res/drawable/arrow_back_disabled.xml +9 -0
  23. package/android/src/main/res/drawable/arrow_back_enabled.xml +9 -0
  24. package/android/src/main/res/drawable/arrow_forward_disabled.xml +9 -0
  25. package/android/src/main/res/drawable/arrow_forward_enabled.xml +9 -0
  26. package/android/src/main/res/drawable/ic_clear_24px.xml +9 -0
  27. package/android/src/main/res/drawable/ic_refresh.xml +9 -0
  28. package/android/src/main/res/drawable/ic_share.xml +10 -0
  29. package/android/src/main/res/layout/activity_browser.xml +32 -0
  30. package/android/src/main/res/layout/bridge_layout_main.xml +15 -0
  31. package/android/src/main/res/layout/content_browser.xml +17 -0
  32. package/android/src/main/res/layout/tool_bar.xml +87 -0
  33. package/android/src/main/res/values/browser_theme.xml +3 -0
  34. package/android/src/main/res/values/colors.xml +5 -0
  35. package/android/src/main/res/values/dimens.xml +3 -0
  36. package/android/src/main/res/values/strings.xml +13 -0
  37. package/android/src/main/res/values/styles.xml +4 -0
  38. package/android/src/main/res/values/themes.xml +27 -0
  39. package/android/src/main/res/xml/file_paths.xml +14 -0
  40. package/dist/docs.json +3200 -0
  41. package/dist/esm/definitions.d.ts +1507 -0
  42. package/dist/esm/definitions.js +40 -0
  43. package/dist/esm/definitions.js.map +1 -0
  44. package/dist/esm/index.d.ts +6 -0
  45. package/dist/esm/index.js +102 -0
  46. package/dist/esm/index.js.map +1 -0
  47. package/dist/esm/web.d.ts +47 -0
  48. package/dist/esm/web.js +120 -0
  49. package/dist/esm/web.js.map +1 -0
  50. package/dist/plugin.cjs.js +270 -0
  51. package/dist/plugin.cjs.js.map +1 -0
  52. package/dist/plugin.js +273 -0
  53. package/dist/plugin.js.map +1 -0
  54. package/ios/Sources/InAppBrowserPlugin/Enums.swift +65 -0
  55. package/ios/Sources/InAppBrowserPlugin/InAppBrowserPlugin.swift +1932 -0
  56. package/ios/Sources/InAppBrowserPlugin/ProxySchemeHandler.swift +1185 -0
  57. package/ios/Sources/InAppBrowserPlugin/WKWebView+SchemeHandling.swift +43 -0
  58. package/ios/Sources/InAppBrowserPlugin/WKWebViewController.swift +3032 -0
  59. package/package.json +139 -0
@@ -0,0 +1,869 @@
1
+ package ee.forgr.capacitor_inappbrowser;
2
+
3
+ import com.getcapacitor.JSObject;
4
+ import java.io.IOException;
5
+ import java.net.URL;
6
+ import java.nio.charset.StandardCharsets;
7
+ import java.util.Base64;
8
+ import java.util.HashMap;
9
+ import java.util.LinkedHashMap;
10
+ import java.util.List;
11
+ import java.util.Locale;
12
+ import java.util.Map;
13
+ import java.util.regex.Pattern;
14
+ import org.json.JSONException;
15
+ import org.json.JSONObject;
16
+
17
+ final class ProxyRequestSupport {
18
+
19
+ private record ParsedJsonString(String value, int nextIndex) {}
20
+
21
+ record WebResourceResponseMetadata(String mimeType, String encoding) {}
22
+
23
+ record ParsedResponseHeaders(Map<String, String> responseHeaders, List<String> cookieHeaders) {}
24
+
25
+ static final int SYNTHETIC_NATIVE_FAILURE_STATUS = 599;
26
+ static final String SYNTHETIC_NATIVE_FAILURE_HEADER = "X-Capgo-Proxy-Error";
27
+
28
+ private static final String[] SAFE_MARKER_HEADER_NAMES = {
29
+ "Accept",
30
+ "Accept-Encoding",
31
+ "Accept-Language",
32
+ "Cache-Control",
33
+ "Pragma",
34
+ "If-Modified-Since",
35
+ "If-None-Match",
36
+ "If-Match",
37
+ "If-Unmodified-Since",
38
+ "If-Range",
39
+ "Range",
40
+ "User-Agent"
41
+ };
42
+ private static final String[] CROSS_ORIGIN_REDIRECT_HEADER_NAMES = {
43
+ "Authorization",
44
+ "Cookie",
45
+ "Cookie2",
46
+ "Origin",
47
+ "Proxy-Authorization",
48
+ "Referer"
49
+ };
50
+ /**
51
+ * Revalidation headers that can make the upstream answer with 304 Not Modified, which
52
+ * {@link android.webkit.WebResourceResponse#setStatusCodeAndReasonPhrase(int, String)} rejects
53
+ * (status code must not be in [300, 399]) and crashes the renderer. Scoped intentionally
54
+ * narrow: {@code If-Match} / {@code If-Unmodified-Since} guard writes (return 412 on mismatch,
55
+ * not 304) and {@code If-Range} downgrades to a full 200, so they must NOT be stripped here.
56
+ */
57
+ private static final String[] CACHE_VALIDATOR_HEADER_NAMES = { "If-None-Match", "If-Modified-Since" };
58
+
59
+ private ProxyRequestSupport() {}
60
+
61
+ static boolean shouldInjectBridge(Options options) {
62
+ return options != null && options.shouldEnableNativeProxy();
63
+ }
64
+
65
+ static boolean usesLegacyJsProxyMode(Options options) {
66
+ if (options == null) {
67
+ return false;
68
+ }
69
+ return (
70
+ (options.getProxyRequests() || options.getProxyRequestsPattern() != null) &&
71
+ options.getOutboundProxyRules().isEmpty() &&
72
+ options.getInboundProxyRules().isEmpty()
73
+ );
74
+ }
75
+
76
+ static Pattern compileProxyRequestsPattern(Object rawProxyRequests) {
77
+ if (!(rawProxyRequests instanceof String proxyRequestsPattern) || proxyRequestsPattern.isBlank()) {
78
+ return null;
79
+ }
80
+ return Pattern.compile(proxyRequestsPattern);
81
+ }
82
+
83
+ static boolean matchesProxyRequestsPattern(Pattern pattern, String requestUrl) {
84
+ if (pattern == null) {
85
+ return true;
86
+ }
87
+ if (requestUrl == null || requestUrl.isBlank()) {
88
+ return false;
89
+ }
90
+ return pattern.matcher(requestUrl).find();
91
+ }
92
+
93
+ static boolean shouldDelegateLegacyJsProxyRequest(Options options, String requestUrl) {
94
+ return usesLegacyJsProxyMode(options) && matchesProxyRequestsPattern(options.getProxyRequestsPattern(), requestUrl);
95
+ }
96
+
97
+ static boolean shouldHandleNonBridgeRequest(Options options, String requestUrl) {
98
+ if (options == null) {
99
+ return false;
100
+ }
101
+ if (
102
+ !options.getProxyRequests() &&
103
+ options.getProxyRequestsPattern() == null &&
104
+ options.getOutboundProxyRules().isEmpty() &&
105
+ options.getInboundProxyRules().isEmpty()
106
+ ) {
107
+ return false;
108
+ }
109
+ return !usesLegacyJsProxyMode(options) || matchesProxyRequestsPattern(options.getProxyRequestsPattern(), requestUrl);
110
+ }
111
+
112
+ static boolean isBridgeMarkerRequestUrl(String requestUrl) {
113
+ if (requestUrl == null || requestUrl.isBlank()) {
114
+ return false;
115
+ }
116
+ try {
117
+ URL parsedUrl = new URL(requestUrl);
118
+ return "/_capgo_proxy_".equals(parsedUrl.getPath()) && parsedUrl.getQuery() != null && !parsedUrl.getQuery().isBlank();
119
+ } catch (Exception error) {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ static Map<String, String> mergeRequestHeaders(Map<String, String> nativeHeaders, String storedHeadersJson) throws JSONException {
125
+ Map<String, String> mergedHeaders = new LinkedHashMap<>();
126
+ if (nativeHeaders != null) {
127
+ mergedHeaders.putAll(nativeHeaders);
128
+ }
129
+ if (storedHeadersJson == null || storedHeadersJson.isEmpty()) {
130
+ return mergedHeaders;
131
+ }
132
+
133
+ parseFlatJsonObject(storedHeadersJson, mergedHeaders);
134
+
135
+ return mergedHeaders;
136
+ }
137
+
138
+ static Map<String, String> extractSafeMarkerHeaders(Map<String, String> markerHeaders) {
139
+ Map<String, String> safeHeaders = new LinkedHashMap<>();
140
+ if (markerHeaders == null || markerHeaders.isEmpty()) {
141
+ return safeHeaders;
142
+ }
143
+
144
+ for (String safeHeaderName : SAFE_MARKER_HEADER_NAMES) {
145
+ String existingKey = findHeaderKeyIgnoreCase(markerHeaders, safeHeaderName);
146
+ if (existingKey == null) {
147
+ continue;
148
+ }
149
+ String value = markerHeaders.get(existingKey);
150
+ if (value == null || value.isBlank()) {
151
+ continue;
152
+ }
153
+ safeHeaders.put(existingKey, value);
154
+ }
155
+
156
+ return safeHeaders;
157
+ }
158
+
159
+ static Map<String, String> mergeMissingHeaders(Map<String, String> primaryHeaders, Map<String, String> fallbackHeaders) {
160
+ Map<String, String> mergedHeaders = new LinkedHashMap<>();
161
+ if (primaryHeaders != null) {
162
+ mergedHeaders.putAll(primaryHeaders);
163
+ }
164
+ if (fallbackHeaders == null || fallbackHeaders.isEmpty()) {
165
+ return mergedHeaders;
166
+ }
167
+
168
+ for (Map.Entry<String, String> entry : fallbackHeaders.entrySet()) {
169
+ String key = entry.getKey();
170
+ String value = entry.getValue();
171
+ if (key == null || value == null || value.isBlank()) {
172
+ continue;
173
+ }
174
+ if (findHeaderKeyIgnoreCase(mergedHeaders, key) != null) {
175
+ continue;
176
+ }
177
+ mergedHeaders.put(key, value);
178
+ }
179
+
180
+ return mergedHeaders;
181
+ }
182
+
183
+ static boolean shouldInjectCookies(String credentialsMode, String initiatorUrl, String requestUrl, Map<String, String> requestHeaders) {
184
+ if (findHeaderKeyIgnoreCase(requestHeaders, "Cookie") != null) {
185
+ return false;
186
+ }
187
+
188
+ String normalizedCredentialsMode = normalizeCredentialsMode(credentialsMode);
189
+ if ("omit".equals(normalizedCredentialsMode)) {
190
+ return false;
191
+ }
192
+ if ("include".equals(normalizedCredentialsMode)) {
193
+ return true;
194
+ }
195
+ return isSameOrigin(initiatorUrl, requestUrl);
196
+ }
197
+
198
+ static boolean supportsNativeHttpRequest(URL url) {
199
+ if (url == null) {
200
+ return false;
201
+ }
202
+ String protocol = url.getProtocol();
203
+ return "http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol);
204
+ }
205
+
206
+ static String resolveRedirectUrl(String requestUrl, int statusCode, Map<String, String> responseHeaders) {
207
+ if (requestUrl == null || !isFollowableRedirectStatus(statusCode)) {
208
+ return null;
209
+ }
210
+
211
+ String location = findHeaderIgnoreCase(responseHeaders, "Location");
212
+ if (location == null || location.isBlank()) {
213
+ return null;
214
+ }
215
+
216
+ try {
217
+ return new URL(new URL(requestUrl), location).toString();
218
+ } catch (Exception error) {
219
+ return null;
220
+ }
221
+ }
222
+
223
+ static boolean supportsWebResourceResponseStatus(int statusCode) {
224
+ return (statusCode >= 100 && statusCode <= 299) || (statusCode >= 400 && statusCode <= 599);
225
+ }
226
+
227
+ static boolean shouldFallbackToWebViewForUnsupportedStatus(boolean bridgeBackedRequest, int statusCode) {
228
+ return !bridgeBackedRequest && !supportsWebResourceResponseStatus(statusCode);
229
+ }
230
+
231
+ static boolean hasHeaderIgnoreCase(Map<String, String> headers, String expectedKey) {
232
+ return findHeaderKeyIgnoreCase(headers, expectedKey) != null;
233
+ }
234
+
235
+ static String resolveRedirectMethod(String method, int statusCode) {
236
+ String normalizedMethod = normalizeMethod(method);
237
+ if ("HEAD".equals(normalizedMethod)) {
238
+ return normalizedMethod;
239
+ }
240
+ if (statusCode == 303) {
241
+ return "GET";
242
+ }
243
+ if ("GET".equals(normalizedMethod)) {
244
+ return normalizedMethod;
245
+ }
246
+ if ((statusCode == 301 || statusCode == 302) && "POST".equals(normalizedMethod)) {
247
+ return "GET";
248
+ }
249
+ return normalizedMethod;
250
+ }
251
+
252
+ static boolean shouldPreserveRequestBodyOnRedirect(String method, int statusCode) {
253
+ String normalizedMethod = normalizeMethod(method);
254
+ if ("GET".equals(normalizedMethod) || "HEAD".equals(normalizedMethod)) {
255
+ return false;
256
+ }
257
+ if (statusCode == 301 || statusCode == 302) {
258
+ return !"POST".equals(normalizedMethod);
259
+ }
260
+ return statusCode == 307 || statusCode == 308;
261
+ }
262
+
263
+ static String resolveOverrideUrl(String requestUrl, String overrideUrl) {
264
+ if (overrideUrl == null || overrideUrl.isBlank()) {
265
+ return requestUrl;
266
+ }
267
+
268
+ try {
269
+ return new URL(new URL(requestUrl), overrideUrl).toString();
270
+ } catch (Exception error) {
271
+ return overrideUrl;
272
+ }
273
+ }
274
+
275
+ static String normalizeOverrideMethod(String method) {
276
+ return normalizeMethod(method);
277
+ }
278
+
279
+ static String resolveBootstrapBaseUrl(String initialUrl, String effectiveUrl) {
280
+ if (effectiveUrl == null || effectiveUrl.isBlank()) {
281
+ return initialUrl;
282
+ }
283
+ return effectiveUrl;
284
+ }
285
+
286
+ static Map<String, String> prepareRedirectHeaders(
287
+ Map<String, String> originalHeaders,
288
+ boolean preserveRequestBody,
289
+ String requestUrl,
290
+ String redirectUrl
291
+ ) {
292
+ Map<String, String> redirectedHeaders = new LinkedHashMap<>();
293
+ if (originalHeaders != null) {
294
+ redirectedHeaders.putAll(originalHeaders);
295
+ }
296
+ if (isCrossOriginRedirect(requestUrl, redirectUrl)) {
297
+ dropHeadersIgnoreCase(redirectedHeaders, CROSS_ORIGIN_REDIRECT_HEADER_NAMES);
298
+ }
299
+ if (preserveRequestBody) {
300
+ return redirectedHeaders;
301
+ }
302
+
303
+ dropHeaderIgnoreCase(redirectedHeaders, "Content-Encoding");
304
+ dropHeaderIgnoreCase(redirectedHeaders, "Content-Language");
305
+ dropHeaderIgnoreCase(redirectedHeaders, "Content-Length");
306
+ dropHeaderIgnoreCase(redirectedHeaders, "Content-Location");
307
+ dropHeaderIgnoreCase(redirectedHeaders, "Content-Type");
308
+ dropHeaderIgnoreCase(redirectedHeaders, "Transfer-Encoding");
309
+ return redirectedHeaders;
310
+ }
311
+
312
+ /**
313
+ * Returns a copy of {@code headers} with revalidation headers removed
314
+ * ({@code If-None-Match}, {@code If-Modified-Since}). Used to keep proxied native requests
315
+ * fresh so the upstream cannot answer with 304, which would crash WebResourceResponse on
316
+ * Android. Other preconditions ({@code If-Match}, {@code If-Unmodified-Since}, {@code If-Range})
317
+ * are deliberately preserved because dropping them would change semantics — e.g., turning a
318
+ * guarded write into an unconditional one.
319
+ */
320
+ static Map<String, String> stripCacheValidatorHeaders(Map<String, String> headers) {
321
+ Map<String, String> copy = new LinkedHashMap<>();
322
+ if (headers != null) {
323
+ copy.putAll(headers);
324
+ }
325
+ dropHeadersIgnoreCase(copy, CACHE_VALIDATOR_HEADER_NAMES);
326
+ return copy;
327
+ }
328
+
329
+ static Map<String, String> prepareOverrideHeaders(Map<String, String> originalHeaders, String requestUrl, String overrideUrl) {
330
+ Map<String, String> overrideHeaders = new LinkedHashMap<>();
331
+ if (originalHeaders != null) {
332
+ overrideHeaders.putAll(originalHeaders);
333
+ }
334
+ if (isCrossOriginRedirect(requestUrl, overrideUrl)) {
335
+ dropHeadersIgnoreCase(overrideHeaders, CROSS_ORIGIN_REDIRECT_HEADER_NAMES);
336
+ }
337
+ return overrideHeaders;
338
+ }
339
+
340
+ static String resolveOverrideBody(String currentBase64Body, String method, boolean hasBodyOverride, String overrideBody) {
341
+ if (shouldDropRequestBody(method)) {
342
+ return "";
343
+ }
344
+ if (!hasBodyOverride) {
345
+ return currentBase64Body != null ? currentBase64Body : "";
346
+ }
347
+ return overrideBody != null ? overrideBody : "";
348
+ }
349
+
350
+ static byte[] decodeBase64Body(String base64Body) throws IOException {
351
+ if (base64Body == null || base64Body.isEmpty()) {
352
+ return new byte[0];
353
+ }
354
+
355
+ try {
356
+ return Base64.getMimeDecoder().decode(base64Body);
357
+ } catch (IllegalArgumentException error) {
358
+ throw new IOException("Invalid base64 request body", error);
359
+ }
360
+ }
361
+
362
+ static boolean shouldLetWebViewHandleMissingBody(String requestUrl, String method, String base64Body) {
363
+ if (isBridgeMarkerRequestUrl(requestUrl)) {
364
+ return false;
365
+ }
366
+ if (!requiresCapturedRequestBody(method)) {
367
+ return false;
368
+ }
369
+ return base64Body == null || base64Body.isEmpty();
370
+ }
371
+
372
+ static WebResourceResponseMetadata resolveWebResourceResponseMetadata(String contentType, Map<String, String> responseHeaders) {
373
+ String resolvedContentType = firstNonEmpty(contentType, findHeaderIgnoreCase(responseHeaders, "Content-Type"));
374
+ if (resolvedContentType == null || resolvedContentType.isBlank()) {
375
+ return new WebResourceResponseMetadata("application/octet-stream", null);
376
+ }
377
+
378
+ String[] contentTypeParts = resolvedContentType.split(";");
379
+ String mimeType = contentTypeParts[0].trim();
380
+ if (mimeType.isEmpty()) {
381
+ mimeType = "application/octet-stream";
382
+ }
383
+
384
+ String encoding = null;
385
+ if (isTextLikeMimeType(mimeType)) {
386
+ String parsedCharset = null;
387
+ for (int index = 1; index < contentTypeParts.length; index++) {
388
+ String parameter = contentTypeParts[index].trim();
389
+ int separatorIndex = parameter.indexOf('=');
390
+ if (separatorIndex <= 0) {
391
+ continue;
392
+ }
393
+ String key = parameter.substring(0, separatorIndex).trim();
394
+ if (!"charset".equalsIgnoreCase(key)) {
395
+ continue;
396
+ }
397
+ parsedCharset = stripOptionalQuotes(parameter.substring(separatorIndex + 1).trim());
398
+ if (!parsedCharset.isEmpty()) {
399
+ break;
400
+ }
401
+ }
402
+ encoding = parsedCharset != null && !parsedCharset.isEmpty() ? parsedCharset : "utf-8";
403
+ }
404
+
405
+ return new WebResourceResponseMetadata(mimeType, encoding);
406
+ }
407
+
408
+ static WebResourceResponseMetadata resolveWebResourceResponseConstructorMetadata(
409
+ String contentType,
410
+ Map<String, String> responseHeaders
411
+ ) {
412
+ if (hasHeaderIgnoreCase(responseHeaders, "Content-Type")) {
413
+ return new WebResourceResponseMetadata(null, null);
414
+ }
415
+ return resolveWebResourceResponseMetadata(contentType, responseHeaders);
416
+ }
417
+
418
+ static ParsedResponseHeaders splitResponseHeaders(Map<String, List<String>> rawHeaders) {
419
+ Map<String, String> responseHeaders = new HashMap<>();
420
+ List<String> cookieHeaders = new java.util.ArrayList<>();
421
+ if (rawHeaders == null || rawHeaders.isEmpty()) {
422
+ return new ParsedResponseHeaders(responseHeaders, cookieHeaders);
423
+ }
424
+
425
+ for (Map.Entry<String, List<String>> entry : rawHeaders.entrySet()) {
426
+ String headerName = entry.getKey();
427
+ List<String> headerValues = entry.getValue();
428
+ if (headerName == null || headerValues == null || headerValues.isEmpty()) {
429
+ continue;
430
+ }
431
+
432
+ if (isCookieResponseHeader(headerName)) {
433
+ for (String headerValue : headerValues) {
434
+ if (headerValue != null && !headerValue.isBlank()) {
435
+ cookieHeaders.add(headerValue);
436
+ }
437
+ }
438
+ continue;
439
+ }
440
+
441
+ String headerValue = headerValues
442
+ .stream()
443
+ .filter((value) -> value != null && !value.isBlank())
444
+ .reduce((first, second) -> first + ", " + second)
445
+ .orElse(null);
446
+ if (headerValue == null) {
447
+ continue;
448
+ }
449
+ responseHeaders.put(headerName, headerValue);
450
+ }
451
+
452
+ return new ParsedResponseHeaders(responseHeaders, cookieHeaders);
453
+ }
454
+
455
+ static ParsedResponseHeaders splitSyntheticResponseHeaders(Map<String, String> rawHeaders) {
456
+ Map<String, String> responseHeaders = new HashMap<>();
457
+ List<String> cookieHeaders = new java.util.ArrayList<>();
458
+ if (rawHeaders == null || rawHeaders.isEmpty()) {
459
+ return new ParsedResponseHeaders(responseHeaders, cookieHeaders);
460
+ }
461
+
462
+ for (Map.Entry<String, String> entry : rawHeaders.entrySet()) {
463
+ String headerName = entry.getKey();
464
+ String headerValue = entry.getValue();
465
+ if (headerName == null || headerValue == null || headerValue.isBlank()) {
466
+ continue;
467
+ }
468
+
469
+ if (isCookieResponseHeader(headerName)) {
470
+ cookieHeaders.add(headerValue);
471
+ continue;
472
+ }
473
+
474
+ responseHeaders.put(headerName, headerValue);
475
+ }
476
+
477
+ return new ParsedResponseHeaders(responseHeaders, cookieHeaders);
478
+ }
479
+
480
+ static String describeNativeRequestFailure(IOException error) {
481
+ if (error == null || error.getMessage() == null || error.getMessage().isBlank()) {
482
+ return "Native proxy request failed";
483
+ }
484
+ String sanitizedMessage = sanitizeHeaderValue(error.getMessage());
485
+ return sanitizedMessage.isBlank() ? "Native proxy request failed" : sanitizedMessage;
486
+ }
487
+
488
+ static Map<String, String> createNativeRequestFailureHeaders(IOException error) {
489
+ Map<String, String> headers = new HashMap<>();
490
+ headers.put("Cache-Control", "no-store");
491
+ headers.put("Content-Type", "text/plain; charset=utf-8");
492
+ headers.put(SYNTHETIC_NATIVE_FAILURE_HEADER, describeNativeRequestFailure(error));
493
+ return headers;
494
+ }
495
+
496
+ static byte[] createNativeRequestFailureBody(String requestUrl, IOException error) {
497
+ StringBuilder message = new StringBuilder("Native proxy request failed");
498
+ if (requestUrl != null && !requestUrl.isBlank()) {
499
+ message.append(" for ").append(requestUrl);
500
+ }
501
+ message.append(": ").append(describeNativeRequestFailure(error));
502
+ return message.toString().getBytes(StandardCharsets.UTF_8);
503
+ }
504
+
505
+ static JSObject normalizeLegacySyntheticResponse(JSONObject legacyResponse) {
506
+ JSObject normalizedResponse = new JSObject();
507
+ if (legacyResponse == null) {
508
+ normalizedResponse.put("status", 200);
509
+ normalizedResponse.put("body", "");
510
+ return normalizedResponse;
511
+ }
512
+
513
+ normalizedResponse.put(
514
+ "status",
515
+ normalizeLegacySyntheticStatus(
516
+ legacyResponse.has("status") ? legacyResponse.optInt("status") : null,
517
+ legacyResponse.has("code") ? legacyResponse.optInt("code") : null
518
+ )
519
+ );
520
+ normalizedResponse.put(
521
+ "body",
522
+ normalizeLegacySyntheticBody(
523
+ legacyResponse.opt("body"),
524
+ legacyResponse.optBoolean("base64Encoded", false) ||
525
+ legacyResponse.optBoolean("bodyIsBase64", false) ||
526
+ legacyResponse.optBoolean("isBase64", false)
527
+ )
528
+ );
529
+
530
+ JSONObject headers = legacyResponse.optJSONObject("headers");
531
+ if (headers != null) {
532
+ Map<String, Object> normalizedHeaderMap = new LinkedHashMap<>();
533
+ java.util.Iterator<String> keys = headers.keys();
534
+ while (keys.hasNext()) {
535
+ String key = keys.next();
536
+ Object value = headers.opt(key);
537
+ normalizedHeaderMap.put(key, value);
538
+ }
539
+ JSObject normalizedHeaders = new JSObject();
540
+ for (Map.Entry<String, String> entry : normalizeLegacyStringMap(normalizedHeaderMap).entrySet()) {
541
+ normalizedHeaders.put(entry.getKey(), entry.getValue());
542
+ }
543
+ normalizedResponse.put("headers", normalizedHeaders);
544
+ }
545
+
546
+ return normalizedResponse;
547
+ }
548
+
549
+ static int normalizeLegacySyntheticStatus(Integer status, Integer code) {
550
+ if (status != null) {
551
+ return status;
552
+ }
553
+ if (code != null) {
554
+ return code;
555
+ }
556
+ return 200;
557
+ }
558
+
559
+ static String normalizeLegacySyntheticBody(Object rawBody, boolean isBase64Body) {
560
+ if (rawBody == null || rawBody == JSONObject.NULL) {
561
+ return "";
562
+ }
563
+
564
+ String normalizedBody = rawBody.toString();
565
+ if (isBase64Body) {
566
+ return normalizedBody;
567
+ }
568
+ return Base64.getEncoder().encodeToString(normalizedBody.getBytes(StandardCharsets.UTF_8));
569
+ }
570
+
571
+ static Map<String, String> normalizeLegacyStringMap(Map<String, ?> rawMap) {
572
+ Map<String, String> normalizedMap = new LinkedHashMap<>();
573
+ if (rawMap == null || rawMap.isEmpty()) {
574
+ return normalizedMap;
575
+ }
576
+
577
+ for (Map.Entry<String, ?> entry : rawMap.entrySet()) {
578
+ String key = entry.getKey();
579
+ Object value = entry.getValue();
580
+ if (key == null || value == null || value == JSONObject.NULL) {
581
+ continue;
582
+ }
583
+ normalizedMap.put(key, value.toString());
584
+ }
585
+
586
+ return normalizedMap;
587
+ }
588
+
589
+ private static boolean requiresCapturedRequestBody(String method) {
590
+ return !shouldDropRequestBody(method);
591
+ }
592
+
593
+ private static boolean shouldDropRequestBody(String method) {
594
+ String normalizedMethod = normalizeMethod(method);
595
+ return "GET".equals(normalizedMethod) || "HEAD".equals(normalizedMethod);
596
+ }
597
+
598
+ private static String normalizeMethod(String method) {
599
+ if (method == null) {
600
+ return "GET";
601
+ }
602
+ return method.trim().toUpperCase(Locale.US);
603
+ }
604
+
605
+ private static String sanitizeHeaderValue(String value) {
606
+ StringBuilder sanitizedValue = new StringBuilder(value.length());
607
+ boolean previousWasSpace = false;
608
+ for (int index = 0; index < value.length(); index++) {
609
+ char character = value.charAt(index);
610
+ boolean shouldReplaceWithSpace = character <= 31 || character == 127;
611
+ if (shouldReplaceWithSpace) {
612
+ if (!previousWasSpace) {
613
+ sanitizedValue.append(' ');
614
+ previousWasSpace = true;
615
+ }
616
+ continue;
617
+ }
618
+ sanitizedValue.append(character);
619
+ previousWasSpace = character == ' ';
620
+ }
621
+ return sanitizedValue.toString().trim();
622
+ }
623
+
624
+ private static String normalizeCredentialsMode(String credentialsMode) {
625
+ if (credentialsMode == null) {
626
+ return "same-origin";
627
+ }
628
+ String normalizedCredentialsMode = credentialsMode.trim().toLowerCase(Locale.US);
629
+ if ("omit".equals(normalizedCredentialsMode) || "include".equals(normalizedCredentialsMode)) {
630
+ return normalizedCredentialsMode;
631
+ }
632
+ return "same-origin";
633
+ }
634
+
635
+ private static boolean isFollowableRedirectStatus(int statusCode) {
636
+ return statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308;
637
+ }
638
+
639
+ private static boolean isCrossOriginRedirect(String requestUrl, String redirectUrl) {
640
+ try {
641
+ URL request = new URL(requestUrl);
642
+ URL redirect = new URL(redirectUrl);
643
+ return (
644
+ !request.getProtocol().equalsIgnoreCase(redirect.getProtocol()) ||
645
+ !request.getHost().equalsIgnoreCase(redirect.getHost()) ||
646
+ effectivePort(request) != effectivePort(redirect)
647
+ );
648
+ } catch (Exception error) {
649
+ return true;
650
+ }
651
+ }
652
+
653
+ private static boolean isSameOrigin(String firstUrl, String secondUrl) {
654
+ if (firstUrl == null || secondUrl == null || firstUrl.isBlank() || secondUrl.isBlank()) {
655
+ return false;
656
+ }
657
+ return !isCrossOriginRedirect(firstUrl, secondUrl);
658
+ }
659
+
660
+ private static void parseFlatJsonObject(String json, Map<String, String> target) throws JSONException {
661
+ String trimmedJson = json.trim();
662
+ if (trimmedJson.isEmpty() || "{}".equals(trimmedJson)) {
663
+ return;
664
+ }
665
+ if (trimmedJson.charAt(0) != '{' || trimmedJson.charAt(trimmedJson.length() - 1) != '}') {
666
+ throw new JSONException("Expected JSON object");
667
+ }
668
+
669
+ int index = 1;
670
+ while (index < trimmedJson.length() - 1) {
671
+ index = skipWhitespace(trimmedJson, index);
672
+ if (index >= trimmedJson.length() - 1) {
673
+ break;
674
+ }
675
+
676
+ ParsedJsonString key = parseJsonString(trimmedJson, index);
677
+ index = skipWhitespace(trimmedJson, key.nextIndex());
678
+ if (index >= trimmedJson.length() || trimmedJson.charAt(index) != ':') {
679
+ throw new JSONException("Expected ':' after JSON object key");
680
+ }
681
+
682
+ index = skipWhitespace(trimmedJson, index + 1);
683
+ ParsedJsonString value = parseJsonString(trimmedJson, index);
684
+ putHeaderCaseInsensitive(target, key.value(), value.value());
685
+
686
+ index = skipWhitespace(trimmedJson, value.nextIndex());
687
+ if (index >= trimmedJson.length() - 1) {
688
+ break;
689
+ }
690
+ char separator = trimmedJson.charAt(index);
691
+ if (separator == ',') {
692
+ index++;
693
+ continue;
694
+ }
695
+ if (separator == '}') {
696
+ break;
697
+ }
698
+ throw new JSONException("Expected ',' or '}' in JSON object");
699
+ }
700
+ }
701
+
702
+ private static ParsedJsonString parseJsonString(String json, int startIndex) throws JSONException {
703
+ if (startIndex >= json.length() || json.charAt(startIndex) != '"') {
704
+ throw new JSONException("Expected JSON string");
705
+ }
706
+
707
+ StringBuilder value = new StringBuilder();
708
+ int index = startIndex + 1;
709
+ while (index < json.length()) {
710
+ char currentChar = json.charAt(index);
711
+ if (currentChar == '"') {
712
+ return new ParsedJsonString(value.toString(), index + 1);
713
+ }
714
+ if (currentChar != '\\') {
715
+ value.append(currentChar);
716
+ index++;
717
+ continue;
718
+ }
719
+
720
+ index++;
721
+ if (index >= json.length()) {
722
+ throw new JSONException("Invalid escape sequence in JSON string");
723
+ }
724
+
725
+ char escapedChar = json.charAt(index);
726
+ switch (escapedChar) {
727
+ case '"':
728
+ case '\\':
729
+ case '/':
730
+ value.append(escapedChar);
731
+ index++;
732
+ break;
733
+ case 'b':
734
+ value.append('\b');
735
+ index++;
736
+ break;
737
+ case 'f':
738
+ value.append('\f');
739
+ index++;
740
+ break;
741
+ case 'n':
742
+ value.append('\n');
743
+ index++;
744
+ break;
745
+ case 'r':
746
+ value.append('\r');
747
+ index++;
748
+ break;
749
+ case 't':
750
+ value.append('\t');
751
+ index++;
752
+ break;
753
+ case 'u':
754
+ if (index + 4 >= json.length()) {
755
+ throw new JSONException("Invalid unicode escape in JSON string");
756
+ }
757
+ String hexValue = json.substring(index + 1, index + 5);
758
+ try {
759
+ value.append((char) Integer.parseInt(hexValue, 16));
760
+ } catch (NumberFormatException error) {
761
+ throw new JSONException("Invalid unicode escape in JSON string");
762
+ }
763
+ index += 5;
764
+ break;
765
+ default:
766
+ throw new JSONException("Unsupported escape sequence in JSON string");
767
+ }
768
+ }
769
+
770
+ throw new JSONException("Unterminated JSON string");
771
+ }
772
+
773
+ private static int skipWhitespace(String value, int index) {
774
+ int currentIndex = index;
775
+ while (currentIndex < value.length() && Character.isWhitespace(value.charAt(currentIndex))) {
776
+ currentIndex++;
777
+ }
778
+ return currentIndex;
779
+ }
780
+
781
+ private static void putHeaderCaseInsensitive(Map<String, String> target, String key, String value) {
782
+ String existingKey = findHeaderIgnoreCase(target, key) != null ? findHeaderKeyIgnoreCase(target, key) : null;
783
+ if (existingKey != null) {
784
+ target.remove(existingKey);
785
+ }
786
+ target.put(key, value);
787
+ }
788
+
789
+ private static void dropHeaderIgnoreCase(Map<String, String> headers, String expectedKey) {
790
+ String existingKey = findHeaderKeyIgnoreCase(headers, expectedKey);
791
+ if (existingKey != null) {
792
+ headers.remove(existingKey);
793
+ }
794
+ }
795
+
796
+ private static void dropHeadersIgnoreCase(Map<String, String> headers, String[] expectedKeys) {
797
+ for (String expectedKey : expectedKeys) {
798
+ dropHeaderIgnoreCase(headers, expectedKey);
799
+ }
800
+ }
801
+
802
+ private static String findHeaderIgnoreCase(Map<String, String> headers, String expectedKey) {
803
+ String resolvedKey = findHeaderKeyIgnoreCase(headers, expectedKey);
804
+ return resolvedKey != null ? headers.get(resolvedKey) : null;
805
+ }
806
+
807
+ private static String findHeaderKeyIgnoreCase(Map<String, String> headers, String expectedKey) {
808
+ if (headers == null || expectedKey == null) {
809
+ return null;
810
+ }
811
+ for (String key : headers.keySet()) {
812
+ if (key != null && key.equalsIgnoreCase(expectedKey)) {
813
+ return key;
814
+ }
815
+ }
816
+ return null;
817
+ }
818
+
819
+ private static String firstNonEmpty(String primaryValue, String fallbackValue) {
820
+ if (primaryValue != null && !primaryValue.isBlank()) {
821
+ return primaryValue;
822
+ }
823
+ if (fallbackValue != null && !fallbackValue.isBlank()) {
824
+ return fallbackValue;
825
+ }
826
+ return null;
827
+ }
828
+
829
+ private static boolean isTextLikeMimeType(String mimeType) {
830
+ if (mimeType == null) {
831
+ return false;
832
+ }
833
+ String normalizedMimeType = mimeType.trim().toLowerCase(Locale.US);
834
+ return (
835
+ normalizedMimeType.startsWith("text/") ||
836
+ normalizedMimeType.equals("application/json") ||
837
+ normalizedMimeType.equals("application/javascript") ||
838
+ normalizedMimeType.equals("application/x-javascript") ||
839
+ normalizedMimeType.equals("application/xml") ||
840
+ normalizedMimeType.equals("application/xhtml+xml") ||
841
+ normalizedMimeType.equals("application/x-www-form-urlencoded") ||
842
+ normalizedMimeType.endsWith("+json") ||
843
+ normalizedMimeType.endsWith("+xml") ||
844
+ normalizedMimeType.endsWith("+javascript")
845
+ );
846
+ }
847
+
848
+ private static boolean isCookieResponseHeader(String headerName) {
849
+ return "Set-Cookie".equalsIgnoreCase(headerName) || "Set-Cookie2".equalsIgnoreCase(headerName);
850
+ }
851
+
852
+ private static String stripOptionalQuotes(String value) {
853
+ if (value == null || value.length() < 2) {
854
+ return value != null ? value : "";
855
+ }
856
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
857
+ return value.substring(1, value.length() - 1);
858
+ }
859
+ return value;
860
+ }
861
+
862
+ private static int effectivePort(URL url) {
863
+ int port = url.getPort();
864
+ if (port != -1) {
865
+ return port;
866
+ }
867
+ return url.getDefaultPort();
868
+ }
869
+ }