Skip to content

Commit

Permalink
Detect cross-origin visit request attempts and propose them as new vi…
Browse files Browse the repository at this point in the history
…sits instead of displaying an error
  • Loading branch information
jayohms committed Mar 28, 2024
1 parent 2c76214 commit 10ff074
Show file tree
Hide file tree
Showing 5 changed files with 96 additions and 25 deletions.
51 changes: 37 additions & 14 deletions turbo/src/main/assets/js/turbo_bridge.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 @@ -168,6 +178,19 @@

// Private

async fetchFailedRequestCrossOriginRedirect(visit, statusCode) {
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenStateAtLeast
import dev.hotwire.turbo.config.pullToRefreshEnabled
import dev.hotwire.turbo.errors.TurboVisitError
import dev.hotwire.turbo.fragments.TurboWebFragmentCallback
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.nav.TurboNavigator
Expand All @@ -21,7 +22,6 @@ import dev.hotwire.turbo.views.TurboView
import dev.hotwire.turbo.views.TurboWebView
import dev.hotwire.turbo.visit.TurboVisit
import dev.hotwire.turbo.visit.TurboVisitAction
import dev.hotwire.turbo.errors.TurboVisitError
import dev.hotwire.turbo.visit.TurboVisitOptions
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -233,6 +233,13 @@ internal class TurboWebFragmentDelegate(
navigator.navigate(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.navigateBack()
navigator.navigate(location, TurboVisitOptions())
}

override fun visitNavDestination(): TurboNavDestination {
return navDestination
}
Expand Down
45 changes: 37 additions & 8 deletions turbo/src/main/kotlin/dev/hotwire/turbo/session/TurboSession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import androidx.lifecycle.lifecycleScope
import androidx.webkit.WebResourceErrorCompat
import androidx.webkit.WebViewClientCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature.*
import androidx.webkit.WebViewFeature.VISUAL_STATE_CALLBACK
import androidx.webkit.WebViewFeature.isFeatureSupported
import dev.hotwire.turbo.config.TurboPathConfiguration
import dev.hotwire.turbo.config.screenshotsEnabled
import dev.hotwire.turbo.delegates.TurboFileChooserDelegate
Expand All @@ -21,12 +22,14 @@ import dev.hotwire.turbo.errors.WebError
import dev.hotwire.turbo.errors.WebSslError
import dev.hotwire.turbo.http.*
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.util.*
import dev.hotwire.turbo.util.isHttpGetRequest
import dev.hotwire.turbo.util.logEvent
import dev.hotwire.turbo.util.runOnUiThread
import dev.hotwire.turbo.util.toJson
import dev.hotwire.turbo.views.TurboWebView
import dev.hotwire.turbo.visit.TurboVisit
import dev.hotwire.turbo.visit.TurboVisitAction
import dev.hotwire.turbo.visit.TurboVisitOptions
import kotlinx.coroutines.*
import java.util.*

/**
Expand Down Expand Up @@ -197,6 +200,33 @@ class TurboSession internal constructor(
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 @@ -284,20 +314,19 @@ class TurboSession internal constructor(
* @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
@@ -1,8 +1,8 @@
package dev.hotwire.turbo.session

import android.webkit.HttpAuthHandler
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.errors.TurboVisitError
import dev.hotwire.turbo.nav.TurboNavDestination
import dev.hotwire.turbo.visit.TurboVisitOptions

internal interface TurboSessionCallback {
Expand All @@ -19,6 +19,7 @@ internal interface TurboSessionCallback {
fun visitCompleted(completedOffline: Boolean)
fun visitLocationStarted(location: String)
fun visitProposedToLocation(location: String, options: TurboVisitOptions)
fun visitProposedToCrossOriginRedirect(location: String)
fun visitNavDestination(): TurboNavDestination
fun formSubmissionStarted(location: String)
fun formSubmissionFinished(location: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,17 @@ class TurboSessionTest {
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 @@ -105,7 +116,7 @@ class TurboSessionTest {
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

0 comments on commit 10ff074

Please sign in to comment.