From cd6041c108063d1b98c9a7cbb10f9efbcb48dab3 Mon Sep 17 00:00:00 2001 From: Jay Ohms Date: Thu, 2 Jan 2025 12:26:00 -0500 Subject: [PATCH] Allow cross-origin redirects from link clicks to propose a new visit --- core/src/main/assets/js/turbo.js | 53 ++++++++++++++----- .../dev/hotwire/core/turbo/session/Session.kt | 46 +++++++++++++--- .../core/turbo/session/SessionCallback.kt | 1 + .../hotwire/core/turbo/session/SessionTest.kt | 13 ++++- .../fragments/HotwireWebFragmentDelegate.kt | 7 +++ 5 files changed, 98 insertions(+), 22 deletions(-) diff --git a/core/src/main/assets/js/turbo.js b/core/src/main/assets/js/turbo.js index e41ae8b..035a5bf 100644 --- a/core/src/main/assets/js/turbo.js +++ b/core/src/main/assets/js/turbo.js @@ -98,18 +98,18 @@ // Adapter interface visitProposedToLocation(location, options) { - if (window.Turbo && Turbo.navigator.locationWithActionIsSamePage(location, options.action)) { - // Scroll to the anchor on the page - TurboSession.visitProposalScrollingToAnchor(location.toString(), JSON.stringify(options)) - Turbo.navigator.view.scrollToAnchorFromLocation(location) - } else if (window.Turbo && Turbo.navigator.location?.href === location.href) { - // Refresh the page without native proposal - TurboSession.visitProposalRefreshingPage(location.toString(), JSON.stringify(options)) - this.visitLocationWithOptionsAndRestorationIdentifier(location, JSON.stringify(options), Turbo.navigator.restorationIdentifier) - } else { - // Propose the visit - TurboSession.visitProposedToLocation(location.toString(), JSON.stringify(options)) - } + if (window.Turbo && Turbo.navigator.locationWithActionIsSamePage(location, options.action)) { + // Scroll to the anchor on the page + TurboSession.visitProposalScrollingToAnchor(location.toString(), JSON.stringify(options)) + Turbo.navigator.view.scrollToAnchorFromLocation(location) + } else if (window.Turbo && Turbo.navigator.location?.href === location.href) { + // Refresh the page without native proposal + TurboSession.visitProposalRefreshingPage(location.toString(), JSON.stringify(options)) + this.visitLocationWithOptionsAndRestorationIdentifier(location, JSON.stringify(options), Turbo.navigator.restorationIdentifier) + } else { + // Propose the visit + TurboSession.visitProposedToLocation(location.toString(), JSON.stringify(options)) + } } // Turbolinks 5 @@ -134,8 +134,18 @@ this.loadResponseForVisitWithIdentifier(visit.identifier) } - visitRequestFailedWithStatusCode(visit, statusCode) { - TurboSession.visitRequestFailedWithStatusCode(visit.identifier, visit.hasCachedSnapshot(), statusCode) + async visitRequestFailedWithStatusCode(visit, statusCode) { + // Turbo does not permit cross-origin fetch redirect attempts and + // they'll lead to a visit request failure. Attempt to see if the + // visit request failure was due to a cross-origin redirect. + const redirect = await this.fetchFailedRequestCrossOriginRedirect(visit, statusCode) + const location = visit.location.toString() + + if (redirect != null) { + TurboSession.visitProposedToCrossOriginRedirect(location, redirect.toString(), visit.identifier) + } else { + TurboSession.visitRequestFailedWithStatusCode(location, visit.identifier, visit.hasCachedSnapshot(), statusCode) + } } visitRequestFinished(visit) { @@ -174,6 +184,21 @@ // Private + async fetchFailedRequestCrossOriginRedirect(visit, statusCode) { + // Non-HTTP status codes are sent by Turbo for network + // failures, including cross-origin fetch redirect attempts. + if (statusCode <= 0) { + try { + const response = await fetch(visit.location, { redirect: "follow" }) + if (response.url != null && response.url.origin != visit.location.origin) { + return response.url + } + } catch {} + } + + return null + } + afterNextRepaint(callback) { if (document.hidden) { callback() diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt index 449e361..6e17079 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt @@ -14,9 +14,9 @@ import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature.VISUAL_STATE_CALLBACK import androidx.webkit.WebViewFeature.isFeatureSupported import dev.hotwire.core.config.Hotwire -import dev.hotwire.core.logging.logEvent import dev.hotwire.core.files.delegates.FileChooserDelegate import dev.hotwire.core.files.delegates.GeolocationPermissionDelegate +import dev.hotwire.core.logging.logEvent import dev.hotwire.core.turbo.errors.HttpError import dev.hotwire.core.turbo.errors.LoadError import dev.hotwire.core.turbo.errors.WebError @@ -26,10 +26,10 @@ import dev.hotwire.core.turbo.offline.* import dev.hotwire.core.turbo.util.isHttpGetRequest import dev.hotwire.core.turbo.util.runOnUiThread import dev.hotwire.core.turbo.util.toJson -import dev.hotwire.core.turbo.webview.HotwireWebView import dev.hotwire.core.turbo.visit.Visit import dev.hotwire.core.turbo.visit.VisitAction import dev.hotwire.core.turbo.visit.VisitOptions +import dev.hotwire.core.turbo.webview.HotwireWebView import java.util.Date /** @@ -195,6 +195,33 @@ class Session( callback { it.visitProposedToLocation(location, options) } } + /** + * Called by Turbo bridge when a cross-origin redirect visit is proposed. + * + * Warning: This method is public so it can be used as a Javascript Interface. + * You should never call this directly as it could lead to unintended behavior. + * + * @param location The original visit location requested. + * @param redirectLocation The cross-origin redirect location. + * @param visitIdentifier A unique identifier for the visit. + */ + @JavascriptInterface + fun visitProposedToCrossOriginRedirect( + location: String, + redirectLocation: String, + visitIdentifier: String + ) { + logEvent("visitProposedToCrossOriginRedirect", + "location" to location, + "redirectLocation" to redirectLocation, + "visitIdentifier" to visitIdentifier + ) + + if (visitIdentifier == currentVisit?.identifier) { + callback { it.visitProposedToCrossOriginRedirect(redirectLocation) } + } + } + /** * Called by Turbo bridge when a new visit proposal will refresh the * current page. @@ -277,25 +304,30 @@ class Session( * Warning: This method is public so it can be used as a Javascript Interface. * You should never call this directly as it could lead to unintended behavior. * + * @param location The location of the failed visit. * @param visitIdentifier A unique identifier for the visit. * @param visitHasCachedSnapshot Whether the visit has a cached snapshot available. * @param statusCode The HTTP status code that caused the failure. */ @JavascriptInterface - fun visitRequestFailedWithStatusCode(visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) { + fun visitRequestFailedWithStatusCode( + location: String, + visitIdentifier: String, + visitHasCachedSnapshot: Boolean, + statusCode: Int + ) { val visitError = HttpError.from(statusCode) logEvent( "visitRequestFailedWithStatusCode", + "location" to location, "visitIdentifier" to visitIdentifier, "visitHasCachedSnapshot" to visitHasCachedSnapshot, "error" to visitError ) - currentVisit?.let { visit -> - if (visitIdentifier == visit.identifier) { - callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) } - } + if (visitIdentifier == currentVisit?.identifier) { + callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) } } } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionCallback.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionCallback.kt index 2fd56fa..0b7b918 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionCallback.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/session/SessionCallback.kt @@ -19,6 +19,7 @@ interface SessionCallback { fun visitCompleted(completedOffline: Boolean) fun visitLocationStarted(location: String) fun visitProposedToLocation(location: String, options: VisitOptions) + fun visitProposedToCrossOriginRedirect(location: String) fun visitDestination(): VisitDestination fun formSubmissionStarted(location: String) fun formSubmissionFinished(location: String) diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt index 07e79d9..0a8819d 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt @@ -80,6 +80,17 @@ class SessionTest { verify(callback).visitProposedToLocation(newLocation, options) } + @Test + fun visitProposedToCrossOriginRedirectFiresCallback() { + val location = "${visit.location}/page" + val redirectLocation = "https://example.com/page" + + session.currentVisit = visit + session.visitProposedToCrossOriginRedirect(location, redirectLocation, visit.identifier) + + verify(callback).visitProposedToCrossOriginRedirect(redirectLocation) + } + @Test fun visitStartedSavesCurrentVisitIdentifier() { val visitIdentifier = "12345" @@ -110,7 +121,7 @@ class SessionTest { val visitIdentifier = "12345" session.currentVisit = visit.copy(identifier = visitIdentifier) - session.visitRequestFailedWithStatusCode(visitIdentifier, true, 500) + session.visitRequestFailedWithStatusCode(visit.location, visitIdentifier, true, 500) verify(callback).requestFailedWithError( visitHasCachedSnapshot = true, diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt index b7e4101..d9fe87a 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt @@ -243,6 +243,13 @@ internal class HotwireWebFragmentDelegate( navigator.route(location, options) } + override fun visitProposedToCrossOriginRedirect(location: String) { + // Pop the current destination from the backstack since it + // resulted in a visit failure due to a cross-origin redirect. + navigator.pop() + navigator.route(location) + } + override fun visitDestination(): VisitDestination { return this }