Skip to content

Commit

Permalink
Allow cross-origin redirects from link clicks to propose a new visit
Browse files Browse the repository at this point in the history
  • Loading branch information
jayohms committed Jan 2, 2025
1 parent 31c1db3 commit cd6041c
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 22 deletions.
53 changes: 39 additions & 14 deletions core/src/main/assets/js/turbo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
46 changes: 39 additions & 7 deletions core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) }
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down

0 comments on commit cd6041c

Please sign in to comment.