Skip to content

Commit

Permalink
Initial work to detect cross-origin visit redirects and propose a new…
Browse files Browse the repository at this point in the history
… visit with the external redirect location
  • Loading branch information
jayohms committed Mar 28, 2024
1 parent 2c76214 commit 17d80a1
Show file tree
Hide file tree
Showing 5 changed files with 73 additions and 28 deletions.
26 changes: 13 additions & 13 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 @@ -135,7 +135,7 @@
}

visitRequestFailedWithStatusCode(visit, statusCode) {
TurboSession.visitRequestFailedWithStatusCode(visit.identifier, visit.hasCachedSnapshot(), statusCode)
TurboSession.visitRequestFailedWithStatusCode(visit.location.toString(), visit.identifier, visit.hasCachedSnapshot(), statusCode)
}

visitRequestFinished(visit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ internal class TurboWebFragmentDelegate(
navigator.navigate(location, options)
}

override fun visitProposedToCrossOriginRedirect(redirectLocation: String) {
navigator.navigateBack()
navigator.navigate(redirectLocation, TurboVisitOptions())
}

override fun visitNavDestination(): TurboNavDestination {
return navDestination
}
Expand Down
58 changes: 44 additions & 14 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 @@ -26,7 +27,6 @@ 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 All @@ -45,6 +45,7 @@ class TurboSession internal constructor(
val webView: TurboWebView
) {
internal var currentVisit: TurboVisit? = null
internal var crossOriginRedirectCandidate: String? = null
internal var coldBootVisitIdentifier = ""
internal var previousOverrideUrlTime = 0L
internal var isColdBooting = false
Expand Down Expand Up @@ -121,6 +122,7 @@ class TurboSession internal constructor(
logEvent("reset")
currentVisit?.identifier = ""
coldBootVisitIdentifier = ""
crossOriginRedirectCandidate = null
restorationIdentifiers.clear()
visitPending = false
isReady = false
Expand Down Expand Up @@ -284,20 +286,40 @@ class TurboSession internal constructor(
* @param statusCode The HTTP status code that caused the failure.
*/
@JavascriptInterface
fun visitRequestFailedWithStatusCode(visitIdentifier: String, visitHasCachedSnapshot: Boolean, statusCode: Int) {
val visitError = HttpError.from(statusCode)
fun visitRequestFailedWithStatusCode(
location: String,
visitIdentifier: String,
visitHasCachedSnapshot: Boolean,
statusCode: Int
) {
if (currentVisit?.identifier != visitIdentifier) {
return
}

logEvent(
"visitRequestFailedWithStatusCode",
"visitIdentifier" to visitIdentifier,
"visitHasCachedSnapshot" to visitHasCachedSnapshot,
"error" to visitError
)
val redirectLocation = crossOriginRedirectCandidate

currentVisit?.let { visit ->
if (visitIdentifier == visit.identifier) {
callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) }
}
// Non-HTTP status codes are sent by turbo.js for network
// failures, including cross-origin fetch redirect attempts.
if (statusCode <= 0 && redirectLocation != null) {
logEvent(
"visitRequestedCrossOriginRedirect",
"location" to location,
"redirectLocation" to redirectLocation
)

callback { it.visitProposedToCrossOriginRedirect(redirectLocation) }
} else {
val visitError = HttpError.from(statusCode)

logEvent(
"visitRequestFailedWithStatusCode",
"location" to location,
"visitIdentifier" to visitIdentifier,
"visitHasCachedSnapshot" to visitHasCachedSnapshot,
"error" to visitError
)

callback { it.requestFailedWithError(visitHasCachedSnapshot, visitError) }
}
}

Expand All @@ -311,6 +333,7 @@ class TurboSession internal constructor(
*/
@JavascriptInterface
fun visitRequestFinished(visitIdentifier: String) {
crossOriginRedirectCandidate = null
logEvent("visitRequestFinished", "visitIdentifier" to visitIdentifier)
}

Expand Down Expand Up @@ -751,6 +774,13 @@ class TurboSession internal constructor(
}

override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
// Turbo does not permit cross-origin fetch redirect attempts and
// they'll lead to a visit request failure. Save cross-origin
// redirect candidates in case the current visit request fails.
if (request.isHttpOptionsRequest() && request.isCorsFetchRequest()) {
crossOriginRedirectCandidate = request.url.toString()
}

return requestInterceptor.interceptRequest(request)
}

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(redirectLocation: 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 @@ -73,6 +73,15 @@ internal fun WebResourceRequest.isHttpGetRequest(): Boolean {
url.scheme?.startsWith("HTTP", ignoreCase = true) == true
}

internal fun WebResourceRequest.isHttpOptionsRequest(): Boolean {
return method.equals("OPTIONS", ignoreCase = true) &&
url.scheme?.startsWith("HTTP", ignoreCase = true) == true
}

internal fun WebResourceRequest.isCorsFetchRequest(): Boolean {
return requestHeaders["Sec-Fetch-Mode"] == "cors"
}

internal fun Any.toJson(): String {
return gson.toJson(this)
}
Expand Down

0 comments on commit 17d80a1

Please sign in to comment.